// ==UserScript== // @name HeyBox Up // @namespace https://cyojkoy.github.io/Payment/ // @version 0.4.58 // @description 补全小黑盒网页收藏:网页收藏列表不显示“插眼”(CY),本脚本单独拉取 CY 列表,在收藏页(/app/user/favour/content)把 CY 条目补进收藏列表末尾,按帖子去重,结果缓存 10 分钟。社区首页(/app/bbs/home)隐藏右栏热门社区等冗余模块;收藏页、文章管理页与文章详情页隐藏右侧二维码、推广与备案栏。帖子详情页(/app/bbs/link/*)默认进入全屏沉浸阅读:封面优先取站点头图,头图加载失败时回退正文最大图,没有可用图片时取消封面而不留空白占位;多图封面的分页条独占图片下方 32px 的空白条带,不覆盖画面;底部阅读进度条独占 46px 栏位,标题、正文、媒体与结尾文字都不被压住;正文连续滚动并支持懒加载图片,右侧竖排点赞/收藏/评论/CY 发布/分享/行距设置;收藏按钮打开收藏夹对话框,可多选加入、取消勾选移除,并能在面板里直接新建收藏夹后保存,无法核对归属的收藏夹标记「待核对」且不会因未勾选而被移除。评论区改用自建侧栏,原生评论区与回复框隐藏;侧栏顶部可发布文章主评论(is_cy: 0),楼层与楼中楼条目均提供回复入口,发送对应的 reply_id 与 root_id,支持表情选择、Ctrl/⌘+Enter 提交、字数统计、提交锁、失败重试与取消回复,并按 is_cy 标记屏蔽插眼评论,楼中楼按回复关系递归成树,用分支竖线逐层缩进展示,缩进到第 4 层后改为同列显示,任意一层都能连整棵子树一起收起,收起处保留短缩进、虚线分支与“N 条回复已收起”提示,嵌套再深也不会把侧栏撑宽,子回复带头像并标注“回复 @昵称”,头像与昵称可点击进入对方主页,登录账号自己发布的楼层与楼中楼回复带“删除”入口,二次确认后调用站点删除接口,并从列表与评论数中同步减去。评论侧栏每次重新打开都回源读取第一页,退出帖子再进入也能看到自己刚发的评论;侧栏顶部另有“刷新评论”按钮,回复提交后先插入一行本地待同步行,再重读目标楼层所在页并合并进已加载列表,同 ID 楼层原位替换、新楼层排到顶部、其他页楼层保持原位置,随后按接口返回的 id、同账号同文本或该楼层新出现的评论 id 轮询该页直到站点回显,确认窗口内未回显的本地行改标“已提交”并在后台稀疏复查;刷新评论与删除也按同一页合并,不再用第一页整页替换列表;写操作后的 10 秒内既不读取也不写入该帖缓存,第一页缓存的新鲜期收窄到 15 秒,重开超过一分钟的旧面板也会自动刷新。非沉浸模式下,原生评论区监听评论列自身的滚动,接近底部时触发站点加载更多,不再依赖整页滚动。纯视频帖生成独立视频阅读页,保留原生播放控件并在没有可用来源时给出提示;帖子尚未渲染时显示可重试的等待页。支持滚轮、方向键、触屏、图片大图与阅读位置记忆,关闭后回到原生双栏。评论预览侧栏同样提供 CY 开关,面板展开后鼠标在卡片上停留即可切换预览,点评论数字始终可用。顶部导航改为社区、收藏、文章三个「SVG + 文本」入口(官方根首页 `/` 不挂载脚本),静态与动态两套 SVG 图标按齿轮设置菜单的“动态导航图标”开关切换:开启后三个入口全部换成常驻描边流转加轻微呼吸的 CSS 动态 SVG,关闭开关或系统启用减少动效时退回完全静止的静态 SVG;帖子页左右两侧纵轴中心提供 SVG 上一篇/下一篇箭头(快捷键 [ 与 ]),切换走站内路由,不整页刷新;下一篇优先从已发现队列按作者与话题降低关联度选取,队列不足时连续拉取信息流补充,相关推荐仅作兜底。社区页(/app/topic/link/*)提供只在本社区内生效的帖子搜索,支持时间筛选与加载更多。宽屏社区页改为左频道、中信息流、右热度三栏工作台,中间栏居中且宽度可在齿轮设置菜单按屏幕百分比调节(预设或自定义数值),窄屏自动退回双栏或单栏。帖子正文内嵌的游戏、专辑、百科、比赛、模组等卡片,以及“我的文章”创作者页、频道页与用户主页补齐深色适配,用户主页隐藏右栏下载二维码等冗余信息,作者信息浮层同样套用主题;正文按主题校正字色对比,段落间距收窄,正文行距可在齿轮设置或阅读页右侧面板按倍数调节。另提供跟随系统、夜航、纯黑、浅色、纸页、雾青六套主题预设,在导航栏一键切换;沉浸阅读的顶栏、互动栏、收藏夹与行距菜单、进度条、提示条与遮罩按当前预设取色,纸页与雾青不再沿用深色阅读器外观,图片与视频仍用中性黑底衬托。需要登录小黑盒:脚本以浏览器内已有登录态请求小黑盒自己的插眼收藏接口(含本地计算的请求签名);浏览与补全只读,点赞、收藏、文章评论、楼层回复、评论删除与 CY 发布仅在用户主动点击时调用站点写接口,不改动站点收藏总数。 // @author Yoko // @homepageURL https://github.com/CYoJkoY/Yoko-ScriptCat-Scripts/blob/main/docs/heybox-up.md // @supportURL https://github.com/CYoJkoY/Yoko-ScriptCat-Scripts/issues // @match https://www.xiaoheihe.cn/* // @match https://xiaoheihe.cn/* // @run-at document-start // @grant none // @noframes // @icon data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='8'%20fill='%23232b36'/%3E%3Cpath%20d='M9%207.5h14a2.5%202.5%200%200%201%202.5%202.5v12a2.5%202.5%200%200%201-2.5%202.5H9A2.5%202.5%200%200%201%206.5%2022V10A2.5%202.5%200%200%201%209%207.5Z'%20fill='none'%20stroke='%238aa7c4'%20stroke-width='1.6'/%3E%3Cpath%20d='M10.5%2016c1.2-2.1%203.1-3.2%205.5-3.2s4.3%201.1%205.5%203.2c-1.2%202.1-3.1%203.2-5.5%203.2S11.7%2018.1%2010.5%2016Z'%20fill='%23a9c0d8'/%3E%3Ccircle%20cx='16'%20cy='16'%20r='1.7'%20fill='%23232b36'/%3E%3C/svg%3E // @license MIT // ==/UserScript== "use strict"; (() => { // scripts/heybox-up/src/config.ts var STORAGE_KEYS = Object.freeze({ hideCy: "heybox-up.hideCy.v1", commentPreview: "heybox-up.commentPreview.v1", navMotion: "heybox-up.navMotion.v1", reader: "heybox-up.reader.v1", feedWidth: "heybox-up.feedWidth.v1", readerLineHeight: "heybox-up.readerLineHeight.v1", theme: "heybox-up.theme.v1" }); var PREFERENCE_DEFAULTS = Object.freeze({ hideCy: false, commentPreview: true, navMotion: true, reader: true, feedWidth: 56, readerLineHeight: 1.7 }); var FEED_WIDTH_MIN = 40; var FEED_WIDTH_MAX = 76; var FEED_WIDTH_STEP = 1; var READER_LINE_HEIGHT_MIN = 1; var READER_LINE_HEIGHT_MAX = 2.2; var READER_LINE_HEIGHT_STEP = 0.05; var READER_LINE_HEIGHT_DEFAULT = 1.7; var REQUEST_TIMEOUT_MS = 15e3; var PAGE_SYNC_INTERVAL_MS = 120; var PAGE_ROUTE = Object.freeze({ ARTICLE: "article", FAVOUR: "favour", FEED: "feed", TOPIC: "topic", CREATOR: "creator", OTHER: "other", OFFICIAL_HOME: "official-home" }); var PAGE_ROUTE_RULES = Object.freeze([ { id: PAGE_ROUTE.OFFICIAL_HOME, pattern: /^\/$/ }, { id: PAGE_ROUTE.ARTICLE, pattern: /^\/app\/bbs\/link\/\d+(?:\/|$)/ }, { id: PAGE_ROUTE.FAVOUR, pattern: /^\/app\/user\/favour\/content(?:\/|$)/ }, { id: PAGE_ROUTE.FEED, pattern: /^\/app\/bbs\/home(?:\/|$)/ }, { id: PAGE_ROUTE.TOPIC, pattern: /^\/app\/topic\/link\/\d+(?:\/|$)/ }, { id: PAGE_ROUTE.CREATOR, pattern: /^\/creator(?:\/|$)/ } ]); var ADAPTED_ROUTES = Object.freeze([ PAGE_ROUTE.ARTICLE, PAGE_ROUTE.FAVOUR, PAGE_ROUTE.FEED, PAGE_ROUTE.TOPIC, PAGE_ROUTE.CREATOR, PAGE_ROUTE.OTHER ]); function isOfficialHomePath(pathname) { return pathname === "/"; } var PAGE_MUTATION_REGION_SELECTORS = Object.freeze({ feed: "#page-bbs-community, #heybox-up-comment-rail", article: "#page-bbs-link, #heybox-up-reader", topic: "#page-topic-link, #heybox-up-community-search", favour: "#page-user-favour, .user-favour, #heybox-up-favour", creator: "#page-creator-content-management, #page-creator-content-management-detail", chrome: ".nav, .nav-actions, #heybox-up-nav, #heybox-up-theme-entry" }); // scripts/heybox-up/src/core/page-sync.ts var pageSyncTasks = /* @__PURE__ */ new Map(); var pageMutationRevisions = { feed: 0, article: 0, topic: 0, favour: 0, creator: 0, chrome: 0 }; var PAGE_MUTATION_REGION_ENTRIES = [ ["feed", PAGE_MUTATION_REGION_SELECTORS.feed], ["article", PAGE_MUTATION_REGION_SELECTORS.article], ["topic", PAGE_MUTATION_REGION_SELECTORS.topic], ["favour", PAGE_MUTATION_REGION_SELECTORS.favour], ["creator", PAGE_MUTATION_REGION_SELECTORS.creator], ["chrome", PAGE_MUTATION_REGION_SELECTORS.chrome] ]; var PAGE_MUTATION_REGION_COMBINED = PAGE_MUTATION_REGION_ENTRIES.map((entry) => entry[1]).join(", "); var PAGE_MUTATION_IGNORE_SELECTOR = '[id^="heybox-up-"], input, textarea, select, [contenteditable]:not([contenteditable="false"])'; var EMBEDDED_MEDIA_SELECTOR = "iframe, video, audio, embed, object"; var EMBEDDED_MEDIA_WRAPPER_SELECTOR = "div.iframe"; var EMBEDDED_MEDIA_OBSERVED_SELECTOR = EMBEDDED_MEDIA_SELECTOR + ", " + EMBEDDED_MEDIA_WRAPPER_SELECTOR; var EMBEDDED_MEDIA_SOURCE_ATTRIBUTES = ["src", "srcdoc", "data"]; var EMBEDDED_MEDIA_ATTRIBUTES = [ ...EMBEDDED_MEDIA_SOURCE_ATTRIBUTES, "width", "height", "hidden", "style" ]; var ARTICLE_LAYOUT_OBSERVED_SELECTOR = "#page-bbs-link"; var PAGE_SYNC_ATTRIBUTE_FILTER = [ ...EMBEDDED_MEDIA_ATTRIBUTES, "class", "data-src", "data-original", "poster" ]; var pageSyncTimer = 0; var pageSyncLastRun = 0; var pageSyncRoute = readPageRoute(); var pageSyncForceAll = false; function readPageRoute(pathname = location.pathname) { for (const rule of PAGE_ROUTE_RULES) { if (rule.pattern.test(pathname)) return rule.id; } return PAGE_ROUTE.OTHER; } function registerPageSync(task, intervalMs = 0, routes = ADAPTED_ROUTES, region = null) { pageSyncTasks.set(task, { intervalMs: Math.max(0, Number(intervalMs) || 0), lastRun: 0, lastRegionRevision: -1, pending: false, routes: new Set(routes), region }); } function runPageSyncTasks() { pageSyncTimer = 0; const now = performance.now(); const route = readPageRoute(); const routeChanged = route !== pageSyncRoute; const forceAll = routeChanged || pageSyncForceAll; pageSyncForceAll = false; pageSyncLastRun = now; pageSyncRoute = route; for (const [task, entry] of pageSyncTasks) { const routeMatches = entry.routes.has(route); if (!routeMatches && !forceAll) { entry.pending = false; continue; } const regionRevision = entry.region === null ? -1 : pageMutationRevisions[entry.region]; if (!forceAll && entry.region !== null && regionRevision === entry.lastRegionRevision) continue; if (!forceAll && now - entry.lastRun < entry.intervalMs) { entry.pending = true; continue; } entry.lastRun = now; entry.lastRegionRevision = regionRevision; entry.pending = false; try { task(); } catch (error) { console.error("[HeyBox Up] DOM sync failed", error); } } let nextDelay = Infinity; for (const [, entry] of pageSyncTasks) { if (!entry.pending) continue; nextDelay = Math.min(nextDelay, Math.max(0, entry.lastRun + entry.intervalMs - now)); } if (nextDelay !== Infinity) { pageSyncTimer = window.setTimeout(runPageSyncTasks, nextDelay); } } function schedulePageSync() { if (pageSyncTimer !== 0) return; const elapsed = performance.now() - pageSyncLastRun; pageSyncTimer = window.setTimeout(runPageSyncTasks, Math.max(0, PAGE_SYNC_INTERVAL_MS - elapsed)); } function scheduleForcedPageSync() { pageSyncForceAll = true; schedulePageSync(); } function bumpRegionsForRoot(root) { for (const [region, selector] of PAGE_MUTATION_REGION_ENTRIES) { if (root.matches(selector)) pageMutationRevisions[region] += 1; } } function notePageMutationRegions(record) { const targetNode = record.target; const target = targetNode.nodeType === Node.ELEMENT_NODE ? targetNode : targetNode.parentElement; if (target === null || !(target instanceof Element)) return false; const closestTracked = target.closest( PAGE_MUTATION_REGION_COMBINED + ", " + PAGE_MUTATION_IGNORE_SELECTOR ); if (closestTracked !== null && closestTracked.matches(PAGE_MUTATION_IGNORE_SELECTOR)) return false; let touched = false; if (closestTracked !== null) { bumpRegionsForRoot(closestTracked); touched = true; } const changedNodes = []; if (record.type === "childList") { for (const node of record.addedNodes) changedNodes.push(node); for (const node of record.removedNodes) changedNodes.push(node); } let removedScriptUi = false; if (record.type === "childList") { for (const node of record.removedNodes) { if (node instanceof Element && (node.id.startsWith("heybox-up-") || node.querySelector('[id^="heybox-up-"]') !== null)) { removedScriptUi = true; break; } } } if (removedScriptUi) { for (const region of Object.keys(pageMutationRevisions)) { pageMutationRevisions[region] += 1; } return true; } for (const node of changedNodes) { if (!(node instanceof Element)) continue; if (node.matches(PAGE_MUTATION_REGION_COMBINED)) { bumpRegionsForRoot(node); touched = true; } for (const root of node.querySelectorAll(PAGE_MUTATION_REGION_COMBINED)) { bumpRegionsForRoot(root); touched = true; } } return touched; } function isObservedAttributeTarget(node) { if (!(node instanceof Element)) return false; if (node.matches(EMBEDDED_MEDIA_OBSERVED_SELECTOR)) return true; return node.closest(ARTICLE_LAYOUT_OBSERVED_SELECTOR) !== null; } function startPageSyncObserver() { if (!document.body || !document.body.isConnected) return; new MutationObserver((records) => { if (notePageSyncedRecords(records)) schedulePageSync(); }).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...PAGE_SYNC_ATTRIBUTE_FILTER] }); window.addEventListener("popstate", scheduleForcedPageSync); window.addEventListener("hashchange", scheduleForcedPageSync); } function notePageSyncedRecords(records) { let relevant = false; for (const record of records) { if (record.type === "attributes" && !isObservedAttributeTarget(record.target)) continue; relevant = notePageMutationRegions(record) || relevant; } return relevant; } var trackedStyleContent = /* @__PURE__ */ new WeakMap(); var constructedStylesById = /* @__PURE__ */ new Map(); var CAN_USE_CONSTRUCTED_STYLES = false; try { const testSheet = typeof CSSStyleSheet === "function" && typeof CSSStyleSheet.prototype.replaceSync === "function" ? new CSSStyleSheet() : null; if (testSheet !== null && "adoptedStyleSheets" in document) { testSheet.replaceSync(":root{}"); CAN_USE_CONSTRUCTED_STYLES = true; } } catch { CAN_USE_CONSTRUCTED_STYLES = false; } function ensureStyleElement(id, css, appendToEnd = false) { if (CAN_USE_CONSTRUCTED_STYLES) { try { let entry = constructedStylesById.get(id); if (entry === void 0) { const sheet = new CSSStyleSheet(); sheet.replaceSync(css); entry = { sheet, css }; constructedStylesById.set(id, entry); } else if (entry.css !== css) { entry.sheet.replaceSync(css); entry.css = css; } const adopted = document.adoptedStyleSheets; if (!adopted.includes(entry.sheet)) { document.adoptedStyleSheets = [...adopted, entry.sheet]; } return; } catch { CAN_USE_CONSTRUCTED_STYLES = false; } } let style = document.getElementById(id); if (style === null) { style = document.createElement("style"); style.id = id; style.textContent = css; trackedStyleContent.set(style, css); } else if (trackedStyleContent.get(style) !== css) { style.textContent = css; trackedStyleContent.set(style, css); } const parent = document.head ?? document.documentElement; if (parent === null) return; if (style.parentNode !== parent || appendToEnd && parent === document.head && parent.lastElementChild !== style) { parent.append(style); } } function removeStyleElement(id) { const entry = constructedStylesById.get(id); if (entry !== void 0) { constructedStylesById.delete(id); const adopted = document.adoptedStyleSheets; if (adopted.includes(entry.sheet)) { document.adoptedStyleSheets = adopted.filter((sheet) => sheet !== entry.sheet); } } document.getElementById(id)?.remove(); } // shared/storage/keyValue.ts var DEFAULT_MESSAGES = { read: "[shared/storage] read failed", write: "[shared/storage] write failed", remove: "[shared/storage] remove failed", parse: "[shared/storage] invalid JSON" }; function reportFailure(options, operation, error) { if (options.onError !== void 0) { options.onError({ operation, error }); return; } console.error(DEFAULT_MESSAGES[operation], error); } function createStringStorage(storage, options = {}) { const memory = /* @__PURE__ */ new Map(); const useCache = options.cache !== false; const read = (key) => { if (useCache) { const cached = memory.get(key); if (cached !== void 0) return cached; } try { const value = storage.getItem(key); if (useCache && value !== null) memory.set(key, value); return value; } catch (error) { reportFailure(options, "read", error); return null; } }; const write = (key, value) => { if (useCache) memory.set(key, value); try { storage.setItem(key, value); return true; } catch (error) { reportFailure(options, "write", error); return false; } }; const remove = (key) => { if (useCache) memory.delete(key); try { storage.removeItem(key); return true; } catch (error) { reportFailure(options, "remove", error); return false; } }; return { read, write, readJson: (key, fallback) => { const raw = read(key); if (raw === null) return fallback; try { return JSON.parse(raw); } catch (error) { reportFailure(options, "parse", error); return fallback; } }, writeJson: (key, value) => { let serialized; try { serialized = JSON.stringify(value); } catch (error) { reportFailure(options, "write", error); return false; } return write(key, serialized); }, remove }; } function isStorageArea(value) { if (typeof value !== "object" || value === null) return false; return "getItem" in value && "setItem" in value && "removeItem" in value; } function resolveArea(kind) { const area = kind === "localStorage" ? window.localStorage : window.sessionStorage; if (!isStorageArea(area)) { throw new Error(kind + " is unavailable in this frame"); } return area; } function getBrowserStorage(kind, options = {}) { return createStringStorage( { getItem: (key) => resolveArea(kind).getItem(key), setItem: (key, value) => resolveArea(kind).setItem(key, value), removeItem: (key) => resolveArea(kind).removeItem(key) }, options ); } // scripts/heybox-up/src/state.ts var silent = { onError: () => void 0 }; var localStore = getBrowserStorage("localStorage", silent); var sessionStore = getBrowserStorage("sessionStorage", silent); var HIDE_CY_EVENT = "heybox-up:hide-cy"; var COMMENT_PREVIEW_EVENT = "heybox-up:comment-preview"; var NAV_MOTION_EVENT = "heybox-up:nav-motion"; var READER_EVENT = "heybox-up:reader"; var HIDE_CY_STYLE_ID = "heybox-up-cy-style"; var HIDE_CY_CLASS = "heybox-up-hide-cy"; function readHideCyPreference() { return localStore.read(STORAGE_KEYS.hideCy) === "1"; } function ensureHideCyStyle() { if (document.getElementById(HIDE_CY_STYLE_ID) !== null) return; const style = document.createElement("style"); style.id = HIDE_CY_STYLE_ID; style.textContent = "body." + HIDE_CY_CLASS + ' [data-heybox-up-cy="1"] { display: none !important; }'; (document.head ?? document.documentElement).append(style); } function applyHideCyPreference(value) { applyHideCyClass(value); document.dispatchEvent(new CustomEvent(HIDE_CY_EVENT, { detail: value })); } function applyHideCyClass(value) { ensureHideCyStyle(); if (document.body !== null) document.body.classList.toggle(HIDE_CY_CLASS, value); } function setHideCyPreference(value) { localStore.write(STORAGE_KEYS.hideCy, value ? "1" : "0"); applyHideCyPreference(value); } function releaseHideCy() { document.body?.classList.remove(HIDE_CY_CLASS); document.getElementById(HIDE_CY_STYLE_ID)?.remove(); } function syncHideCyPreference() { const value = readHideCyPreference(); if ((document.body?.classList.contains(HIDE_CY_CLASS) ?? false) === value) return; applyHideCyClass(value); } function readCommentPreviewPreference() { return localStore.read(STORAGE_KEYS.commentPreview) !== "0"; } function setCommentPreviewPreference(value) { localStore.write(STORAGE_KEYS.commentPreview, value ? "1" : "0"); document.dispatchEvent(new CustomEvent(COMMENT_PREVIEW_EVENT, { detail: value })); } function readNavMotionPreference() { return localStore.read(STORAGE_KEYS.navMotion) !== "0"; } function setNavMotionPreference(value) { localStore.write(STORAGE_KEYS.navMotion, value ? "1" : "0"); document.dispatchEvent(new CustomEvent(NAV_MOTION_EVENT, { detail: value })); } function readReaderPreference() { return localStore.read(STORAGE_KEYS.reader) !== "0"; } function setReaderPreference(value) { localStore.write(STORAGE_KEYS.reader, value ? "1" : "0"); document.dispatchEvent(new CustomEvent(READER_EVENT, { detail: value })); } function normalizeFeedWidth(value) { if (value === null || value === void 0 || value === "") return PREFERENCE_DEFAULTS.feedWidth; const parsed = Math.round(Number(value)); if (!Number.isFinite(parsed)) return PREFERENCE_DEFAULTS.feedWidth; return Math.min(FEED_WIDTH_MAX, Math.max(FEED_WIDTH_MIN, parsed)); } function readFeedWidth() { return normalizeFeedWidth(localStore.read(STORAGE_KEYS.feedWidth)); } function applyFeedWidth(value) { const width = normalizeFeedWidth(value); document.documentElement.style.setProperty("--heybox-up-feed-pct", String(width)); return width; } function setFeedWidthPreference(value) { const width = applyFeedWidth(value); localStore.write(STORAGE_KEYS.feedWidth, String(width)); return width; } function isStepAligned(value, base, step) { const steps = (value - base) / step; return Math.abs(steps - Math.round(steps)) < 1e-6; } function parseFeedWidthInput(value) { if (value === null || value === void 0) return null; const text = String(value).trim(); if (text === "") return null; const parsed = Number(text); if (!Number.isFinite(parsed)) return null; if (!isStepAligned(parsed, FEED_WIDTH_MIN, FEED_WIDTH_STEP)) return null; return Math.min(FEED_WIDTH_MAX, Math.max(FEED_WIDTH_MIN, Math.round(parsed))); } function normalizeReaderLineHeight(value) { if (value === null || value === void 0 || value === "") return PREFERENCE_DEFAULTS.readerLineHeight; const parsed = Number(value); if (!Number.isFinite(parsed)) return PREFERENCE_DEFAULTS.readerLineHeight; return Math.min( READER_LINE_HEIGHT_MAX, Math.max(READER_LINE_HEIGHT_MIN, Math.round(parsed * 100) / 100) ); } function parseReaderLineHeightInput(value) { if (value === null || value === void 0) return null; const text = String(value).trim(); if (text === "") return null; const parsed = Number(text); if (!Number.isFinite(parsed)) return null; if (!isStepAligned(parsed, READER_LINE_HEIGHT_MIN, READER_LINE_HEIGHT_STEP)) return null; return Math.min( READER_LINE_HEIGHT_MAX, Math.max(READER_LINE_HEIGHT_MIN, Math.round(parsed * 100) / 100) ); } function readReaderLineHeight() { return normalizeReaderLineHeight(localStore.read(STORAGE_KEYS.readerLineHeight)); } function applyReaderLineHeight(value) { const lineHeight = normalizeReaderLineHeight(value); document.documentElement.style.setProperty("--heybox-up-reading-line-height", String(lineHeight)); return lineHeight; } function setReaderLineHeightPreference(value) { const lineHeight = applyReaderLineHeight(value); localStore.write(STORAGE_KEYS.readerLineHeight, String(lineHeight)); return lineHeight; } function isCyComment(comment) { if (comment === null || typeof comment !== "object") return false; const record = comment; return record["is_cy"] === 1 || record["is_cy"] === true || record["is_cy"] === "1"; } // scripts/heybox-up/src/core/request.ts function isAbortError(error) { return error instanceof DOMException && error.name === "AbortError"; } function waitForDelay(ms, signal) { if (ms <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { if (signal?.aborted === true) { reject(signal.reason ?? new DOMException("请求已取消", "AbortError")); return; } const onAbort = () => { window.clearTimeout(timer); signal?.removeEventListener("abort", onAbort); reject(signal?.reason ?? new DOMException("请求已取消", "AbortError")); }; const timer = window.setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); } async function fetchWithTimeout(url, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) { const controller = new AbortController(); const externalSignal = options.signal; const abortFromExternal = () => controller.abort(externalSignal?.reason); if (externalSignal?.aborted === true) controller.abort(externalSignal.reason); else externalSignal?.addEventListener("abort", abortFromExternal, { once: true }); const timer = window.setTimeout(() => { controller.abort(new DOMException("请求超时", "TimeoutError")); }, timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { window.clearTimeout(timer); externalSignal?.removeEventListener("abort", abortFromExternal); } } // scripts/heybox-up/src/core/json.ts function isRecord(value) { return typeof value === "object" && value !== null; } function asRecord(value) { return isRecord(value) ? value : null; } function readFiniteNumber(value) { const parsed = typeof value === "number" ? value : Number(value); return Number.isFinite(parsed) ? parsed : null; } // scripts/heybox-up/src/core/signature.ts var SIG_ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"; var MD5_SHIFTS = [ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 ]; var MD5_TABLE = Array.from( { length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 4294967296) >>> 0 ); function toUtf8Bytes(text) { const bytes = []; for (const char of text) { const point = char.codePointAt(0) ?? 0; if (point < 128) { bytes.push(point); } else if (point < 2048) { bytes.push(192 | point >> 6, 128 | point & 63); } else if (point < 65536) { bytes.push(224 | point >> 12, 128 | point >> 6 & 63, 128 | point & 63); } else { bytes.push( 240 | point >> 18, 128 | point >> 12 & 63, 128 | point >> 6 & 63, 128 | point & 63 ); } } return bytes; } function rotateLeft(value, shift) { return (value << shift | value >>> 32 - shift) >>> 0; } function md5Hex(text) { const bytes = toUtf8Bytes(text); const bitLength = bytes.length * 8; bytes.push(128); while (bytes.length % 64 !== 56) bytes.push(0); const high = Math.floor(bitLength / 4294967296) >>> 0; const low = bitLength >>> 0; for (const word of [low, high]) { bytes.push(word & 255, word >>> 8 & 255, word >>> 16 & 255, word >>> 24 & 255); } let stateA = 1732584193; let stateB = 4023233417; let stateC = 2562383102; let stateD = 271733878; for (let offset = 0; offset < bytes.length; offset += 64) { const words = []; for (let index = 0; index < 16; index += 1) { const base = offset + index * 4; words.push( ((bytes[base] ?? 0) | (bytes[base + 1] ?? 0) << 8 | (bytes[base + 2] ?? 0) << 16 | (bytes[base + 3] ?? 0) << 24) >>> 0 ); } let a = stateA; let b = stateB; let c = stateC; let d = stateD; for (let index = 0; index < 64; index += 1) { let mix; let slot; if (index < 16) { mix = b & c | ~b & d; slot = index; } else if (index < 32) { mix = d & b | ~d & c; slot = (5 * index + 1) % 16; } else if (index < 48) { mix = b ^ c ^ d; slot = (3 * index + 5) % 16; } else { mix = c ^ (b | ~d); slot = 7 * index % 16; } const sum = a + mix + (MD5_TABLE[index] ?? 0) + (words[slot] ?? 0) >>> 0; a = d; d = c; c = b; b = b + rotateLeft(sum, MD5_SHIFTS[index] ?? 0) >>> 0; } stateA = stateA + a >>> 0; stateB = stateB + b >>> 0; stateC = stateC + c >>> 0; stateD = stateD + d >>> 0; } let hex = ""; for (const word of [stateA, stateB, stateC, stateD]) { for (let index = 0; index < 4; index += 1) { hex += (word >>> index * 8 & 255).toString(16).padStart(2, "0"); } } return hex; } function mapByAlphabet(value, endIndex) { const source = endIndex < 0 ? SIG_ALPHABET.slice(0, endIndex) : SIG_ALPHABET; let mapped = ""; for (let index = 0; index < value.length; index += 1) { mapped += source[value.charCodeAt(index) % source.length] ?? ""; } return mapped; } function interleave(parts) { const longest = Math.max(...parts.map((part) => part.length)); let merged = ""; for (let index = 0; index < longest; index += 1) { for (const part of parts) { if (index < part.length) merged += part[index] ?? ""; } } return merged; } function xtime(value) { return value & 128 ? (value << 1 ^ 27) & 255 : value << 1; } function mixColumns(values) { const quartic = (value) => xtime(value) ^ value; const r = (value) => quartic(xtime(value)); const y = (value) => r(quartic(xtime(value))); const g = (value) => y(value) ^ r(value) ^ quartic(value); const v0 = values[0] ?? 0; const v1 = values[1] ?? 0; const v2 = values[2] ?? 0; const v3 = values[3] ?? 0; values[0] = g(v0) ^ y(v1) ^ r(v2) ^ quartic(v3); values[1] = quartic(v0) ^ g(v1) ^ y(v2) ^ r(v3); values[2] = r(v0) ^ quartic(v1) ^ g(v2) ^ y(v3); values[3] = y(v0) ^ r(v1) ^ quartic(v2) ^ g(v3); return values; } function createSignature(path, time, nonce) { const normalizedPath = "/" + path.split("/").filter(Boolean).join("/") + "/"; const seed = interleave([ mapByAlphabet(String(time + 1), -2), mapByAlphabet(normalizedPath, 0), mapByAlphabet(nonce, 0) ]).slice(0, 20); const digest = md5Hex(seed); const tail = digest.slice(-6).split("").map((char) => char.charCodeAt(0)); const checksum = String( mixColumns(tail).reduce((total, value) => total + value, 0) % 100 ).padStart(2, "0"); return mapByAlphabet(digest.slice(0, 5), -4) + checksum; } // scripts/heybox-up/src/core/cy.ts var CY_API_PATH = "/bbs/app/profile/bbs/comment/list"; var CY_PAGE_SIZE = 20; var CY_MAX_ITEMS = 200; var CACHE_TTL_MS = 10 * 60 * 1e3; var CACHE_KEY_PREFIX = "heybox-up.cy.v1:"; var LINK_HREF_PREFIX = "/app/bbs/link/"; function buildCyApiUrl(userId, offset) { const time = Math.floor(Date.now() / 1e3); const nonce = md5Hex(String(time) + String(Math.random())).toUpperCase(); const hkey = createSignature(CY_API_PATH, time, nonce); const query = new URLSearchParams({ os_type: "web", app: "heybox", client_type: "web", version: "999.0.4", web_version: "2.5", x_client_type: "web", x_app: "heybox_website", heybox_id: userId, x_os_type: "Windows", device_info: "Chrome", hkey, _time: String(time), nonce, userid: userId, only_cy: "1", filter_type: "0", enable_new_style_collect: "1", limit: String(CY_PAGE_SIZE), offset: String(offset), no_more: "false" }); return "https://api.xiaoheihe.cn" + CY_API_PATH + "?" + query.toString(); } function readCyPageRecords(result) { if (Array.isArray(result)) return result; const record = asRecord(result); if (record === null) return []; for (const key of ["links", "list", "comment_list"]) { const value = record[key]; if (Array.isArray(value)) return value; } return []; } function readCyPageFinished(data, list) { const nested = asRecord(data["result"]); const hasNext = data["has_next"] ?? (nested === null ? void 0 : nested["has_next"]); if (hasNext === "0" || hasNext === 0 || hasNext === false) return true; if (hasNext === "1" || hasNext === 1 || hasNext === true) return false; if (data["no_more"] === true) return true; return list.length === 0; } function readCyLinkId(link) { const raw = link["linkid"] ?? link["id"]; if (raw === null || raw === void 0) return ""; const text = String(raw).trim(); return text === "" ? "" : text; } async function fetchCyPage(userId, offset, signal) { const response = await fetchWithTimeout(buildCyApiUrl(userId, offset), { credentials: "include", headers: { accept: "application/json" }, ...signal === void 0 || signal === null ? {} : { signal } }); if (!response.ok) throw new Error("接口返回 HTTP " + response.status); const raw = await response.json(); const data = asRecord(raw) ?? {}; if (data["status"] !== "ok") throw new Error(describeCyApiFailure(data)); const list = readCyPageRecords(data["result"]); return { list, noMore: readCyPageFinished(data, list) }; } function describeCyApiFailure(data) { const record = asRecord(data); const rawMessage = record === null ? void 0 : record["msg"]; const message = typeof rawMessage === "string" ? rawMessage.trim() : ""; if (message === "hkey 不能为空" || message.includes("注销") || message.includes("登录")) { return "登录状态已失效,请重新登录小黑盒后点击刷新。"; } return message !== "" ? message : "接口返回失败状态"; } function normalizeCyItem(raw) { const record = asRecord(raw); if (record === null) return null; const link = asRecord(record["link"]); if (link === null) return null; const linkId = readCyLinkId(link); if (linkId === "") return null; const rawThumbs = link["thumbs"]; const thumbs = Array.isArray(rawThumbs) ? rawThumbs : []; const rawTitle = link["title"]; const rawText = record["text"]; const firstThumb = thumbs[0]; return { id: linkId, title: typeof rawTitle === "string" && rawTitle.trim() !== "" ? rawTitle.trim() : "无标题帖子", text: typeof rawText === "string" ? rawText.trim() : "", createAt: Number(record["create_at"]) || 0, thumb: typeof firstThumb === "string" ? firstThumb : "", kind: record["comment_type"] === 1 ? "回复" : "评论" }; } async function loadCyFavorites(userId, onProgress, signal) { const seen = /* @__PURE__ */ new Set(); const items = []; let offset = 0; for (; ; ) { const page = await fetchCyPage(userId, offset, signal); for (const raw of page.list) { const item = normalizeCyItem(raw); if (item !== null && !seen.has(item.id)) { seen.add(item.id); items.push(item); } } onProgress(items.length); if (page.noMore || items.length >= CY_MAX_ITEMS) break; offset += CY_PAGE_SIZE; if (offset >= CY_MAX_ITEMS + CY_PAGE_SIZE) break; } return items.slice(0, CY_MAX_ITEMS); } function asCachedItem(value) { const record = asRecord(value); if (record === null) return null; const id = record["id"]; const title = record["title"]; const text = record["text"]; const createAt = record["createAt"]; const thumb = record["thumb"]; const kind = record["kind"]; if (typeof id !== "string" || id === "") return null; return { id, title: typeof title === "string" ? title : "", text: typeof text === "string" ? text : "", createAt: typeof createAt === "number" && Number.isFinite(createAt) ? createAt : 0, thumb: typeof thumb === "string" ? thumb : "", kind: typeof kind === "string" ? kind : "" }; } function readCache(userId) { const parsed = localStore.readJson(CACHE_KEY_PREFIX + userId, null); const record = asRecord(parsed); if (record === null) return null; const rawItems = record["items"]; if (!Array.isArray(rawItems)) return null; if (Date.now() - Number(record["savedAt"]) > CACHE_TTL_MS) return null; const items = []; for (const raw of rawItems) { const item = asCachedItem(raw); if (item !== null) items.push(item); } return items; } function writeCache(userId, items) { localStore.writeJson(CACHE_KEY_PREFIX + userId, { savedAt: Date.now(), items }); } function formatTime(value) { if (!value) return ""; const milliseconds = value > 1e12 ? value : value * 1e3; const date = new Date(milliseconds); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleString("zh-CN", { hour12: false }); } function createCard(item) { const card = document.createElement("a"); card.className = "card"; card.href = LINK_HREF_PREFIX + item.id; card.target = "_blank"; card.rel = "noopener noreferrer"; if (item.thumb !== "") { const thumb = document.createElement("img"); thumb.className = "thumb"; thumb.src = item.thumb; thumb.alt = ""; thumb.loading = "lazy"; thumb.referrerPolicy = "no-referrer"; card.append(thumb); } const body = document.createElement("div"); body.className = "body"; const cardTitle = document.createElement("div"); cardTitle.className = "card-title"; cardTitle.textContent = item.title; const cardText = document.createElement("div"); cardText.className = item.text !== "" ? "card-text" : "card-text is-empty"; if (item.text === "") { cardText.textContent = "(无文字内容)"; } else { const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); icon.setAttribute("class", "card-text-icon"); icon.setAttribute("viewBox", "0 0 16 16"); icon.setAttribute("aria-hidden", "true"); const iconPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); iconPath.setAttribute("fill", "currentColor"); iconPath.setAttribute( "d", "M8 3C4.5 3 1.8 5.5 1 8c.8 2.5 3.5 5 7 5s6.2-2.5 7-5c-.8-2.5-3.5-5-7-5Zm0 8.2A3.2 3.2 0 1 1 8 4.8a3.2 3.2 0 0 1 0 6.4Zm0-1.7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" ); icon.append(iconPath); const content = document.createElement("span"); content.className = "card-text-body"; content.textContent = item.text; cardText.append(icon, content); cardText.title = item.text; } const foot = document.createElement("div"); foot.className = "card-foot"; const kind = document.createElement("span"); kind.textContent = "插眼" + item.kind; const time = document.createElement("span"); time.textContent = formatTime(item.createAt); foot.append(kind, time); body.append(cardTitle, cardText, foot); card.append(body); return card; } // scripts/heybox-up/src/core/account.ts function readCookie(name) { const prefix = name + "="; for (const part of document.cookie.split(";")) { const entry = part.trim(); if (entry.startsWith(prefix)) { try { return decodeURIComponent(entry.slice(prefix.length)); } catch { return entry.slice(prefix.length); } } } return ""; } var HEYBOX_ID_PLACEHOLDERS = /* @__PURE__ */ new Set([ "", "None", "none", "NONE", "null", "NULL", "undefined" ]); function normalizeHeyboxIdValue(value) { if (typeof value !== "string") return ""; const text = value.trim(); return HEYBOX_ID_PLACEHOLDERS.has(text) ? "" : text; } function getHeyboxId() { const primary = normalizeHeyboxIdValue(readCookie("heybox_id")); if (primary !== "") return primary; return normalizeHeyboxIdValue(readCookie("user_heybox_id")); } var ACCOUNT_ID_RESTORE_PATH = "/account/restore_login"; var ACCOUNT_ID_CACHE_TTL_MS = 60 * 1e3; var ACCOUNT_ID_STORAGE_KEYS = [ "heybox_id", "user_heybox_id", "account.heybox_id" ]; var accountIdCache = { value: "", at: 0 }; function readAccountIdField(record) { const fields = asRecord(record); if (fields === null) return ""; for (const key of ["heybox_id", "heyboxId", "userid", "user_id"]) { const raw = fields[key]; if (raw === null || raw === void 0) continue; const value = normalizeHeyboxIdValue(String(raw)); if (value !== "") return value; } return ""; } function readAccountIdFromRaw(raw) { if (typeof raw !== "string") return ""; const text = raw.trim(); if (/^\d+$/.test(text)) return text; let parsed; try { parsed = JSON.parse(text); } catch { return ""; } return readAccountIdField(parsed); } function readAccountIdFromStorage() { for (const key of ACCOUNT_ID_STORAGE_KEYS) { const local = readAccountIdFromRaw(localStore.read(key)); if (local !== "") return local; const session = readAccountIdFromRaw(sessionStore.read(key)); if (session !== "") return session; } return ""; } async function readAccountIdFromStore() { const host = Reflect.get(window, "storeV2API"); const store = asRecord(host); if (store === null || typeof store["read"] !== "function") return ""; let pending; try { pending = store["read"](["account.heybox_id"]); } catch { return ""; } if (pending === null || typeof pending !== "object") return ""; if (typeof Reflect.get(pending, "then") !== "function") return ""; let snapshot; try { snapshot = await pending; } catch { return ""; } const result = asRecord(snapshot); if (result === null) return ""; const data = asRecord(result["data"]); if (data === null) return ""; const snapshots = data["snapshots"]; if (!Array.isArray(snapshots) || snapshots.length === 0) return ""; const first = asRecord(snapshots[0]); if (first === null) return ""; const entry = asRecord(first["entry"]); if (entry === null || entry["exists"] !== true) return ""; const value = entry["value"]; if (value === null || value === void 0) return ""; return normalizeHeyboxIdValue(String(value)); } async function readAccountIdFromRestore(signal) { const query = new URLSearchParams(createApiParams(ACCOUNT_ID_RESTORE_PATH)); const url = "https://api.xiaoheihe.cn" + ACCOUNT_ID_RESTORE_PATH + "?" + query.toString(); const response = await fetchWithTimeout(url, { credentials: "include", cache: "no-store", headers: { accept: "application/json" }, ...signal === void 0 || signal === null ? {} : { signal } }); if (!response.ok) return ""; const raw = await response.json(); const data = asRecord(raw); if (data === null || data["status"] !== "ok") return ""; const result = asRecord(data["result"]); if (result === null) return ""; const detail = readAccountIdField(result["account_detail"]); if (detail !== "") return detail; const profile = readAccountIdField(result["profile"]); if (profile !== "") return profile; return readAccountIdField(result); } function rememberAccountId(value) { if (value === "") return ""; accountIdCache.value = value; accountIdCache.at = Date.now(); return value; } async function resolveHeyboxId(signal) { const cookieId = getHeyboxId(); if (cookieId !== "") return rememberAccountId(cookieId); if (accountIdCache.value !== "" && Date.now() - accountIdCache.at < ACCOUNT_ID_CACHE_TTL_MS) { return accountIdCache.value; } const storeId = await readAccountIdFromStore(); if (storeId !== "") return rememberAccountId(storeId); const storageId = readAccountIdFromStorage(); if (storageId !== "") return rememberAccountId(storageId); const restored = await readAccountIdFromRestore(signal); if (restored !== "") return rememberAccountId(restored); return ""; } // scripts/heybox-up/src/ui/rail.ts var RAIL_ID = "heybox-up-comment-rail"; var COMMENT_TREE_API_PATH = "/bbs/app/link/tree"; var COMMENT_CREATE_API_PATH = "/bbs/app/comment/create"; var DELETE_COMMENT_API_PATH = "/bbs/app/comment/delete"; var COMMENT_CREATE_RND_VERSION = "15"; var COMMENT_CREATE_RND_SECRET = "Z7mFG4tQp9Ws2LxB8H"; var RAIL_PAGE_SIZE = 20; var RAIL_TRIGGER_SELECTOR = ".bbs-new-style-bottom__comment, .content-list__comment"; var RAIL_ICONS = { comment: '', external: '', collapse: '' }; var RAIL_CSS = ` [hidden] { display: none !important; } :host { --rail-bg: var(--heybox-up-surface, #fff); --rail-text: var(--heybox-up-text, #14191e); --rail-text-2: var(--heybox-up-text-2, #3c4249); --rail-muted: var(--heybox-up-muted, #6b7280); --rail-subtle: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); --rail-line: var(--heybox-up-border, rgba(20, 25, 30, .1)); --rail-accent: var(--heybox-up-accent, #6f8db3); --rail-radius: var(--heybox-up-radius-card, 14px); position: fixed; top: 80px; right: 20px; width: var(--heybox-up-rail-w, 340px); display: block; /* Below the site navigation (5000) and full-screen loading (2002), above body and editor controls. */ z-index: 1500; font: 13px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; color: var(--rail-text); } :host([data-state="hidden"]) { display: none; } @media (max-width: 1199px) { :host { display: none; } } .panel { display: flex; flex-direction: column; max-height: calc(100vh - 168px); background: var(--rail-bg); border: 1px solid var(--rail-line); border-radius: var(--rail-radius); box-shadow: var(--heybox-up-shadow-2, 0 18px 44px rgba(8, 12, 16, .24)); overflow: hidden; } :host([data-state="collapsed"]) { top: 50%; right: 0; width: auto; transform: translateY(-50%); } :host([data-state="collapsed"]) .panel { border-radius: var(--rail-radius) 0 0 var(--rail-radius); } :host([data-state="collapsed"]) .dock { display: none; } :host([data-state="open"]) .tab { display: none; } .tab { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 12px 7px; border: 0; background: transparent; color: var(--rail-muted); font: inherit; writing-mode: vertical-rl; cursor: pointer; } .tab:hover { background: var(--rail-subtle); color: var(--rail-text); } .tab:focus-visible { outline: 2px solid var(--rail-accent); outline-offset: -2px; } .tab svg { width: 15px; height: 15px; } .dock { display: flex; flex-direction: column; min-height: 0; } .head { display: flex; gap: 8px; padding: 12px 14px 10px; border-bottom: 1px solid var(--rail-line); } .head-text { flex: 1 1 auto; min-width: 0; } .kicker { font-size: 10px; letter-spacing: .12em; color: var(--rail-muted); } .title { margin-top: 3px; font-size: 13px; font-weight: 600; line-height: 1.45; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } .tools { display: flex; flex: none; gap: 2px; } .icon { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; color: var(--rail-muted); text-decoration: none; cursor: pointer; } .icon:hover { background: var(--rail-subtle); color: var(--rail-text); } .icon:focus-visible { outline: 2px solid var(--rail-accent); outline-offset: 1px; } .icon svg { width: 16px; height: 16px; } .icon.cy { width: auto; min-width: 26px; padding: 0 5px; font-size: 10px; font-weight: 600; letter-spacing: .04em; } .icon.cy[aria-pressed="true"] { background: var(--rail-subtle); color: var(--rail-accent); } .meta { padding: 8px 14px 0; font-size: 11px; color: var(--rail-muted); } .list { margin: 0; padding: 8px 0 0; list-style: none; overflow-y: auto; overscroll-behavior: contain; min-height: 0; } .item { display: flex; gap: 10px; padding: 11px 14px; border-top: 1px solid var(--rail-subtle); } .item:first-child { border-top: 0; } .avatar { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; flex: none; background: var(--rail-subtle); } .body { flex: 1 1 auto; min-width: 0; } .who { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; } .name { font-size: 12px; font-weight: 600; color: var(--rail-text); } .time, .likes, .replies { font-size: 11px; color: var(--rail-muted); } .text { margin: 3px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--rail-text); } .text .emoji, .quote .emoji { display: inline-block; width: 17px; height: 17px; margin: 0 1px; vertical-align: -3px; background-repeat: no-repeat; background-size: 102px 306px; } .text .pic, .quote .pic { max-width: 100%; max-height: 160px; border-radius: 6px; object-fit: contain; vertical-align: top; } .foot { display: flex; gap: 10px; margin-top: 4px; } .sub { display: grid; gap: 4px; margin-top: 7px; padding-left: 9px; border-left: 2px solid var(--rail-subtle); } .quote { font-size: 12px; color: var(--rail-text-2); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } .quote .quote-name { color: var(--rail-muted); } .status { padding: 26px 18px; text-align: center; color: var(--rail-muted); } .status.retry { cursor: pointer; text-decoration: underline; text-underline-offset: 3px; } .status a { color: var(--rail-accent); text-decoration: underline; text-underline-offset: 3px; } .foot-bar { display: flex; gap: 8px; padding: 11px 12px; border-top: 1px solid var(--rail-line); } .action { flex: 1 1 auto; height: 30px; border: 1px solid var(--rail-line); border-radius: 8px; background: transparent; color: var(--rail-text); font: inherit; cursor: pointer; } .action:hover { background: var(--rail-subtle); } .action:focus-visible { outline: 2px solid var(--rail-accent); outline-offset: 1px; } .action.primary { border-color: transparent; background: var(--rail-accent); color: var(--heybox-up-on-accent, #fff); } `; function createApiParams(path) { const time = Math.floor(Date.now() / 1e3); const nonce = md5Hex(String(time) + String(Math.random())).toUpperCase(); return { os_type: "web", app: "heybox", client_type: "web", version: "999.0.4", web_version: "2.5", x_client_type: "web", x_app: "heybox_website", x_os_type: "Windows", device_info: "Chrome", hkey: createSignature(path, time, nonce), _time: String(time), nonce }; } async function createCommentRnd(nonce, timestamp) { if (globalThis.crypto === void 0 || globalThis.crypto.subtle === void 0) { throw new Error("当前浏览器不支持 CY 发布签名"); } const encoder = new TextEncoder(); const key = await globalThis.crypto.subtle.importKey( "raw", encoder.encode(COMMENT_CREATE_RND_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const message = COMMENT_CREATE_RND_SECRET + nonce + timestamp + ":" + nonce; const signature = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(message)); const digest = Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); return COMMENT_CREATE_RND_VERSION + ":" + digest; } function buildLinkCommentUrl(linkId, page, signed, accountId = getHeyboxId()) { const base = signed === false ? { heybox_id: accountId, os_type: "web", app: "heybox", client_type: "web", version: "999.0.4", web_version: "2.5", x_client_type: "web", x_app: "heybox_website", x_os_type: "Windows", device_info: "Chrome" } : createApiParams(COMMENT_TREE_API_PATH); const query = new URLSearchParams({ ...base, heybox_id: accountId, link_id: String(linkId), is_first: page === 1 ? "1" : "0", page: String(page), index: "1", limit: String(RAIL_PAGE_SIZE), owner_only: "0" }); return "https://api.xiaoheihe.cn" + COMMENT_TREE_API_PATH + "?" + query.toString().replace("&link_id=", "&h_src&link_id="); } function normalizeRailText(value) { return String(value === void 0 || value === null ? "" : value).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 400); } var RAIL_BODY_MAX = 4e3; var RAIL_EMOJI_SPRITE = "https://static.max-c.com/heybox_web/emoji/cube/cube_emoji_v33.png"; var RAIL_EMOJI_CELL = 17; var RAIL_EMOJI_TOKEN = /\[cube_([^\]]{1,64})\]/g; var RAIL_EMOJI_NAMES = [ "哭泣", "酷", "doge", "喜欢", "黑人问号", "惊讶", "开心", "捂脸哭", "晕", "感动", "委屈", "并不简单", "乖", "笑cry", "怒", "滑稽", "沧桑", "凄凉", "赞", "学习", "叹气", "加油", "摊手", "喷水", "打脸", "H币", "生气", "困", "闭嘴", "吐", "咕咕", "微笑", "哇", "汗", "吓", "睡觉", "2023", "圣诞树", "庆祝", "庆祝-圣诞", "你懂我", "我懂你", "阳", "比心", "wota", "鹅", "握草", "这是什么鸟", "上学-乐", "上学-丧", "打咩", "超人", "僵尸", "小鸡", "窝囊", "摸摸头", "电牛", "摘墨镜", "悟空", "2024", "良民", "嬉水女王", "2025", "浪人砍一刀", "吃鸡啦", "鸡毙你", "机智", "石化", "吹口哨", "思考", "太酷啦", "蛋糕", "毁灭战士", "P的谎言", "剑星伊芙", "欧润吉", "剑星渡鸦", "猛男微笑", "时间旅者", "来财", "我方了", "喜+1", "2026", "害羞", "吃瓜", "菜doge", "柠檬", "比耶", "爱心", "玫瑰", "+1", "-1", "点赞", "盒十", "耶", "鼓掌", "碰拳", "马年吉祥", "红包", "炒菜", "山姆无奈", "洛的点赞", "灵光一闪", "露莉亚", "傲娇助手", "认真记录", "鳄鱼", "星乐蒂" ]; var RAIL_EMOJI_NAME_IDS = new Map( RAIL_EMOJI_NAMES.map((name, index) => [name, String((Math.floor(index / 6) + 1) * 10 + index % 6 + 1)]) ); function isRailEmojiId(value) { const id = Number(value); if (!Number.isInteger(id) || id < 11 || id > 186) return false; const unit = id % 10; const row = Math.floor(id / 10); return unit >= 1 && unit <= 6 && row >= 1 && row <= 18; } function resolveRailEmojiId(value) { const raw = String(value === void 0 || value === null ? "" : value).trim(); const candidate = raw.startsWith("cube_") ? raw.slice(5) : raw; if (isRailEmojiId(candidate)) return String(Number(candidate)); return RAIL_EMOJI_NAME_IDS.get(candidate) || ""; } function parseRailBody(value) { const source = String(value === void 0 || value === null ? "" : value).slice(0, RAIL_BODY_MAX); if (source === "") return []; const template = document.createElement("template"); template.innerHTML = source; const segments = []; const pushText = (value2) => { const parts = String(value2).split(RAIL_EMOJI_TOKEN); for (let index = 0; index < parts.length; index += 1) { if (index % 2 === 1) { const emojiId = resolveRailEmojiId(parts[index]); if (emojiId !== "") segments.push({ type: "emoji", id: emojiId }); else segments.push({ type: "text", text: "[cube_" + parts[index] + "]" }); continue; } const collapsed = parts[index].replace(/\s+/g, " "); if (collapsed.trim() === "") continue; segments.push({ type: "text", text: collapsed }); } }; const walk = (node) => { for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { pushText(child.nodeValue); continue; } if (child.nodeType !== Node.ELEMENT_NODE || !(child instanceof Element)) continue; const element = child; const emoji = element.getAttribute("data-emoji"); if (emoji !== null && emoji !== "") { const match = /(?:^|\s)hb-emoji-cube_([^\s]+)/.exec(String(element.className || "")); const candidates = [match === null ? "" : match[1], emoji]; const id = candidates.map(resolveRailEmojiId).find((item) => item !== "") || ""; if (id !== "") segments.push({ type: "emoji", id }); else segments.push({ type: "text", text: "[" + emoji + "]" }); continue; } if (element.tagName === "IMG") { const src = element.getAttribute("src"); if (src !== null && /^https?:\/\//i.test(src)) segments.push({ type: "image", src }); continue; } if (element.tagName === "BR") { pushText(" "); continue; } walk(element); } }; walk(template.content); if (segments.length > 0) { const first = segments[0]; if (first.type === "text") first.text = first.text.replace(/^\s+/, ""); const last = segments[segments.length - 1]; if (last.type === "text") last.text = last.text.replace(/\s+$/, ""); } return segments.filter((segment) => segment.type !== "text" || segment.text !== "").slice(0, 80); } function railBodyText(segments) { return segments.filter((segment) => segment.type === "text").map((segment) => segment.text).join(" ").trim(); } function normalizeRailComment(comment) { const raw = asRecord(comment) ?? {}; const user = asRecord(raw["user"]) ?? {}; const avatar = user["avatar"] || user["avartar"] || ""; const body = parseRailBody(raw["text"]); const id = String(raw["comment_id"] || raw["commentid"] || raw["id"] || ""); const rawRootId = readRailRelationId(raw, ["root_id", "root_comment_id", "reply_root_id"]); return { id, userId: readRailCommentUserId(user), name: String(user["username"] || user["nickname"] || "匿名用户").trim(), replyName: readRailReplyName(raw), avatar: typeof avatar === "string" ? avatar : "", text: railBodyText(body), body, createAt: Number(raw["create_at"]) || 0, likes: Number(raw["support_num"] || raw["support_count"]) || 0, replies: Number(raw["reply_count"] || raw["sub_comment_num"]) || 0, // A root that points at itself is not a usable parent; the group builder // falls back to the floor id instead. rootId: rawRootId === id ? "" : rawRootId, parentId: readRailRelationId(raw, ["parent_id", "replyid", "reply_id", "reply_to_comment_id", "reply_comment_id", "reply_to", "to_comment_id"]), isCy: isCyComment(raw) }; } function readRailReplyName(raw) { const replyUser = asRecord(raw["replyuser"]) ?? asRecord(raw["reply_user"]) ?? {}; return String(replyUser["username"] || replyUser["nickname"] || "").trim(); } function readRailCommentUserId(user) { for (const key of ["userid", "user_id", "heybox_id", "id"]) { const value = user[key]; if (value === void 0 || value === null) continue; const text = String(value).trim(); if (text === "" || text === "-1" || text === "0" || text === "undefined" || text === "null") continue; return text; } return ""; } function isOwnRailComment(comment, accountId) { const owner = typeof accountId === "string" ? accountId.trim() : ""; if (owner === "" || comment.userId === "") return false; return comment.userId === owner; } function readRailRelationId(raw, keys) { for (const key of keys) { const value = raw[key]; if (value === void 0 || value === null) continue; const text = String(value).trim(); if (text === "" || text === "-1" || text === "0") continue; return text; } return ""; } function normalizeRailGroup(group) { const source = asRecord(group)?.["comment"]; const list = Array.isArray(source) ? source : []; const root = list[0]; if (root === void 0) return null; const item = normalizeRailComment(root); if (item.id === "" && item.body.length === 0 && item.name === "匿名用户") return null; const seen = /* @__PURE__ */ new Set(); const subComments = list.slice(1).map(normalizeRailComment).filter((quote) => quote.body.length > 0).filter((quote) => { if (quote.id === "") return true; if (seen.has(quote.id)) return false; seen.add(quote.id); return true; }).map((quote) => ({ ...quote, rootId: quote.rootId !== "" ? quote.rootId : item.id, parentId: quote.parentId !== "" ? quote.parentId : item.id })); return { ...item, rootId: item.id, parentId: item.id, subComments, quotes: subComments }; } function buildRailCommentTree(entry) { const source = Array.isArray(entry.subComments) ? entry.subComments : []; const nodes = []; const byId = /* @__PURE__ */ new Map(); for (const comment of source) { if (comment.id !== "" && byId.has(comment.id)) continue; const node = { ...comment, children: [] }; nodes.push(node); if (comment.id !== "") byId.set(comment.id, node); } const parentOf = /* @__PURE__ */ new Map(); for (const node of nodes) { if (node.id === "" || node.parentId === "" || node.parentId === entry.id || node.parentId === node.id) continue; const parent = byId.get(node.parentId); if (parent === void 0 || parent === node) continue; parentOf.set(node, parent); } const detached = /* @__PURE__ */ new Set(); for (const node of nodes) { const seen = /* @__PURE__ */ new Set(); let cursor = node; while (cursor !== void 0 && !seen.has(cursor)) { seen.add(cursor); cursor = parentOf.get(cursor); } if (cursor !== void 0) { for (const member of seen) detached.add(member); } } const roots = []; for (const node of nodes) { if (detached.has(node)) { roots.push(node); continue; } const parent = parentOf.get(node); if (parent === void 0 || detached.has(parent)) { roots.push(node); continue; } parent.children.push(node); } return roots; } function readRailFloorTotal(result) { const value = Number(asRecord(result)?.["total_floor_num"]); return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; } function resolveCommentPageProgress(input) { const loadedGroups = input.previouslyLoadedGroups + input.rawGroupCount; if (input.rawGroupCount <= 0) return { hasMore: false, loadedGroups }; if (input.freshGroupCount <= 0) return { hasMore: false, loadedGroups }; if (input.floorTotal > 0 && loadedGroups >= input.floorTotal) { return { hasMore: false, loadedGroups }; } if (input.floorTotal <= 0 && input.rawGroupCount < RAIL_PAGE_SIZE) { return { hasMore: false, loadedGroups }; } return { hasMore: true, loadedGroups }; } function resolveMergedCommentProgress(input) { if (input.floorTotal > 0) { return { hasMore: input.loadedGroups < input.floorTotal, loadedGroups: input.loadedGroups }; } return { hasMore: input.rawGroupCount >= RAIL_PAGE_SIZE, loadedGroups: input.loadedGroups }; } function buildArticleCommentBody(linkId, text, target) { return { is_cy: "0", link_id: linkId, reply_id: target === null ? "-1" : target.id, root_id: target === null ? "-1" : target.rootId, text }; } function buildDeleteCommentBody(commentId) { return { comment_id: commentId }; } function readCreatedCommentId(result) { const record = asRecord(result); if (record === null) return ""; const nested = asRecord(record["comment"]); const candidates = [record["comment_id"], record["commentid"], record["id"]]; if (nested !== null) candidates.push(nested["comment_id"], nested["commentid"], nested["id"]); for (const candidate of candidates) { if (candidate === void 0 || candidate === null) continue; const text = String(candidate).trim(); if (text === "" || text === "0" || text === "-1" || text === "undefined" || text === "null") continue; return text; } return ""; } function railItemsHaveComment(items, commentId) { if (commentId === "") return false; for (const entry of items) { if (entry.id === commentId) return true; if (railThreadHasComment(buildRailCommentTree(entry), commentId)) return true; } return false; } function railThreadHasComment(nodes, commentId) { for (const node of nodes) { if (node.id === commentId) return true; if (railThreadHasComment(node.children, commentId)) return true; } return false; } function findRailFloorIdForComment(items, commentId) { if (commentId === "") return ""; for (const entry of items) { if (entry.id === commentId) return entry.id; if (railThreadHasComment(buildRailCommentTree(entry), commentId)) return entry.id; } return ""; } function mergeRailPageItems(existing, incoming) { const byId = /* @__PURE__ */ new Map(); for (const entry of incoming) { if (entry.id === "") continue; byId.set(entry.id, entry); } const known = new Set(existing.map((entry) => entry.id)); const merged = []; for (const entry of existing) { const replacement = byId.get(entry.id); merged.push(replacement === void 0 ? entry : replacement); } const fresh = []; for (const entry of incoming) { if (entry.id === "" || known.has(entry.id)) continue; known.add(entry.id); fresh.push(entry); } return fresh.concat(merged); } function countRailThreadNodes(nodes) { let total = 0; for (const node of nodes) { total += 1 + countRailThreadNodes(node.children); } return total; } var RAIL_PENDING_ID_PREFIX = "pending-"; function createRailPendingReply(input) { const text = input.text.trim(); const id = typeof input.commentId === "string" && input.commentId !== "" ? input.commentId : RAIL_PENDING_ID_PREFIX + input.sequence; return { id, userId: input.userId, name: "我", replyName: "", avatar: "", text, body: [{ type: "text", text }], createAt: input.createAt, likes: 0, replies: 0, rootId: input.rootId, parentId: input.parentId, isCy: false }; } function attachRailPendingReplies(entry, pending) { if (pending.length === 0) return entry; const subComments = entry.subComments.concat(pending); return { ...entry, subComments, quotes: subComments }; } var RAIL_ECHO_MENTION = /^(?:回复\s*)?@[^\s@::,,]{1,32}[\s::,,]+/; function stripRailEchoMentions(value) { let text = value; for (let guard = 0; guard < 4; guard += 1) { const next = text.replace(RAIL_ECHO_MENTION, ""); if (next === text) return text; text = next; } return text; } function flattenRailMatchText(value) { return value.replace(/\[cube_[^\]]{1,64}\]/g, " ").replace(/\[[^\]]{1,64}\]/g, " ").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } function railEchoTextMatches(pendingText, echoText) { const wanted = flattenRailMatchText(pendingText); if (wanted === "") return false; return stripRailEchoMentions(flattenRailMatchText(echoText)) === wanted; } function collectRailCommentIds(items) { const ids = /* @__PURE__ */ new Set(); for (const entry of items) { if (entry.id !== "") ids.add(entry.id); for (const node of collectRailEchoedNodes(entry)) ids.add(node.id); } return ids; } function collectRailEchoedNodes(entry) { const nodes = []; const collect = (branch) => { for (const node of branch) { if (node.id !== "" && !node.id.startsWith(RAIL_PENDING_ID_PREFIX)) nodes.push(node); collect(node.children); } }; collect(buildRailCommentTree(entry)); return nodes; } function findRailPendingEcho(nodes, pending, claimedIds, freshIds) { const free = nodes.filter((node) => !claimedIds.has(node.id)); const sameAuthor = (node) => pending.userId === "" || node.userId === "" || node.userId === pending.userId; if (pending.id !== "" && !pending.id.startsWith(RAIL_PENDING_ID_PREFIX)) { const byId = free.find((node) => node.id === pending.id); if (byId !== void 0) return byId; } if (flattenRailMatchText(pending.text) === "") return null; const byText = free.find((node) => sameAuthor(node) && railEchoTextMatches(pending.text, node.text)); if (byText !== void 0) return byText; if (freshIds === void 0) return null; if (pending.userId === "") return null; const fresh = free.filter((node) => node.userId === pending.userId && freshIds.has(node.id)); if (fresh.length === 1) return fresh[0]; const freshWithText = fresh.filter((node) => railEchoTextMatches(pending.text, node.text)); return freshWithText.length === 1 ? freshWithText[0] : null; } function readRailFreshIds(items, entry, pending, knownIds) { const rootLoaded = pending.rootId !== "" && items.some((item) => item.id === pending.rootId); const owns = rootLoaded ? entry.id === pending.rootId : findRailFloorIdForComment(items, pending.parentId) === entry.id; if (!owns) return void 0; return new Set(collectRailEchoedNodes(entry).map((node) => node.id).filter((id) => !knownIds.has(id))); } function railThreadHasPendingReply(entry, pending, knownIds) { const freshIds = knownIds === void 0 ? void 0 : readRailFreshIds([entry], entry, pending, knownIds); return findRailPendingEcho(collectRailEchoedNodes(entry), pending, /* @__PURE__ */ new Set(), freshIds) !== null; } function railItemsHavePendingReply(items, pending, knownIds) { return items.some((entry) => railThreadHasPendingReply(entry, pending, knownIds)); } function pruneRailPendingReplies(items, pending, baselineFor) { if (pending.length === 0) return []; const claimedIds = /* @__PURE__ */ new Set(); const kept = []; for (const row of pending) { const baseline = baselineFor?.(row); let match = null; for (const entry of items) { const freshIds = baseline === void 0 ? void 0 : readRailFreshIds(items, entry, row, baseline); match = findRailPendingEcho(collectRailEchoedNodes(entry), row, claimedIds, freshIds); if (match !== null) break; } if (match === null) { kept.push(row); continue; } claimedIds.add(match.id); } return kept; } var lastRailRequestAt = 0; var RAIL_PAGE_CACHE_TTL_MS = 2 * 60 * 1e3; var RAIL_PAGE_CACHE_FRESH_MS = 15 * 1e3; var RAIL_PAGE_CACHE_HOLDOFF_MS = 10 * 1e3; var RAIL_PAGE_CACHE_MAX = 24; var railPageCache = /* @__PURE__ */ new Map(); var railPageCacheHoldoff = /* @__PURE__ */ new Map(); function holdRailPageCache(linkId, holdoffMs = RAIL_PAGE_CACHE_HOLDOFF_MS) { clearRailPageCache(linkId); if (linkId === "") return; railPageCacheHoldoff.set(linkId, Date.now() + Math.max(0, holdoffMs)); } function isRailPageCacheHeld(linkId) { const until = railPageCacheHoldoff.get(linkId); if (until === void 0) return false; if (Date.now() < until) return true; railPageCacheHoldoff.delete(linkId); return false; } function readRailPageCache(linkId, page, accountId = "", maxAgeMs = RAIL_PAGE_CACHE_TTL_MS) { if (isRailPageCacheHeld(linkId)) return null; const key = linkId + ":" + accountId + ":" + page; const cached = railPageCache.get(key); if (cached === void 0) return null; const limitMs = Number.isFinite(maxAgeMs) ? Math.min(Math.max(0, maxAgeMs), RAIL_PAGE_CACHE_TTL_MS) : RAIL_PAGE_CACHE_TTL_MS; if (Date.now() - cached.savedAt > limitMs) { railPageCache.delete(key); return null; } railPageCache.delete(key); railPageCache.set(key, cached); return { items: cached.data.items, total: cached.data.total, floorTotal: cached.data.floorTotal, title: cached.data.title, link: { ...cached.data.link }, groups: cached.data.groups }; } function writeRailPageCache(linkId, page, accountId, data) { if (isRailPageCacheHeld(linkId)) return; const key = linkId + ":" + accountId + ":" + page; railPageCache.delete(key); railPageCache.set(key, { savedAt: Date.now(), data }); while (railPageCache.size > RAIL_PAGE_CACHE_MAX) { const oldest = railPageCache.keys().next().value; if (oldest === void 0) break; railPageCache.delete(oldest); } } function clearRailPageCache(linkId) { const prefix = linkId + ":"; for (const key of railPageCache.keys()) { if (key.startsWith(prefix)) railPageCache.delete(key); } } function describeRequestError(error, fallback) { return error instanceof Error && error.message !== "" ? error.message : fallback; } function readRailErrorMessage(record) { const msg = record["msg"]; if (typeof msg === "string" && msg !== "") return msg; return "接口返回 " + String(record["status"]); } function countCyInGroups(groups) { let total = 0; const visit = (node) => { if (Array.isArray(node)) { for (const child of node) visit(child); return; } if (!node || typeof node !== "object") return; if (isCyComment(node)) total += 1; for (const value of Object.values(node)) { if (value && typeof value === "object") visit(value); } }; visit(groups); return total; } function readCyCount(link, groups) { const candidates = [link["cy_num"], link["cy_count"], link["cy_total"]]; for (const candidate of candidates) { if (candidate === void 0 || candidate === null || candidate === "") continue; const value = Number(candidate); if (Number.isFinite(value) && value >= 0) return Math.floor(value); } return countCyInGroups(groups); } async function fetchRailPage(linkId, page, options = {}) { const accountId = typeof options.accountId === "string" ? options.accountId : getHeyboxId(); const maxAgeMs = typeof options.maxAgeMs === "number" ? options.maxAgeMs : RAIL_PAGE_CACHE_TTL_MS; const cached = options.bypassCache === true ? null : readRailPageCache(linkId, page, accountId, maxAgeMs); if (cached !== null) return cached; const signal = options.signal; const wait = Math.max(0, 700 - (Date.now() - lastRailRequestAt)); await waitForDelay(wait, signal); lastRailRequestAt = Date.now(); const request = async (signed) => { const init = { credentials: "include", cache: "no-store", headers: { accept: "application/json" } }; if (signal !== void 0 && signal !== null) init.signal = signal; const response = await fetchWithTimeout(buildLinkCommentUrl(linkId, page, signed, accountId), init); if (!response.ok) throw new Error("接口返回 HTTP " + response.status); const payload = await response.json(); const record = asRecord(payload); if (record === null) throw new Error("接口返回 undefined"); if (record["status"] !== "ok") throw new Error(readRailErrorMessage(record)); return record; }; let data; try { data = await request(true); } catch (primaryError) { if (isAbortError(primaryError)) throw primaryError; try { data = await request(false); } catch (fallbackError) { if (isAbortError(fallbackError)) throw fallbackError; throw new Error(describeRequestError(primaryError, "签名请求失败") + " / 匿名请求:" + describeRequestError(fallbackError, "失败")); } } const result = asRecord(data["result"]) ?? {}; const rawGroups = result["comments"]; const rawComment = result["comment"]; const groups = Array.isArray(rawGroups) ? rawGroups : Array.isArray(rawComment) ? rawComment : []; const link = asRecord(result["link"]) ?? {}; const normalized = { items: groups.map(normalizeRailGroup).filter((item) => item !== null), total: Number(link["comment_num"] || result["total_floor_num"]) || 0, floorTotal: readRailFloorTotal(result), title: typeof link["title"] === "string" ? link["title"].trim() : "", link: { isAward: Number(link["is_award_link"]) === 1, isFavour: Number(link["is_favour"]) === 1, awardCount: Number(link["link_award_num"]) || 0, favourCount: Number(link["favour_count"]) || 0, commentCount: Number(link["comment_num"] || result["total_floor_num"]) || 0, cyCount: readCyCount(link, groups) }, groups }; writeRailPageCache(linkId, page, accountId, normalized); return { ...normalized, link: { ...normalized.link } }; } function formatRailAge(value) { const seconds = Math.floor(Date.now() / 1e3) - Number(value || 0); if (!Number.isFinite(seconds) || seconds < 60) return "刚刚"; if (seconds < 3600) return Math.floor(seconds / 60) + " 分钟前"; if (seconds < 86400) return Math.floor(seconds / 3600) + " 小时前"; if (seconds < 86400 * 30) return Math.floor(seconds / 86400) + " 天前"; return formatTime(value).slice(0, 10); } function createCommentRail() { const host = document.createElement("div"); host.id = RAIL_ID; host.dataset["state"] = "hidden"; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = RAIL_CSS; const panel = document.createElement("div"); panel.className = "panel"; let loadController = null; const tab = document.createElement("button"); tab.type = "button"; tab.className = "tab"; tab.setAttribute("aria-label", "展开评论预览"); tab.setAttribute("aria-expanded", "false"); tab.innerHTML = RAIL_ICONS.comment + "评论预览"; const dock = document.createElement("div"); dock.className = "dock"; const head = document.createElement("div"); head.className = "head"; const headText = document.createElement("div"); headText.className = "head-text"; const kicker = document.createElement("div"); kicker.className = "kicker"; kicker.textContent = "评论预览"; const titleNode = document.createElement("div"); titleNode.className = "title"; titleNode.textContent = "评论"; headText.append(kicker, titleNode); const tools = document.createElement("div"); tools.className = "tools"; const cyButton = document.createElement("button"); cyButton.type = "button"; cyButton.className = "icon cy"; cyButton.textContent = "CY"; const openLinkNode = document.createElement("a"); openLinkNode.className = "icon"; openLinkNode.target = "_blank"; openLinkNode.rel = "noopener noreferrer"; openLinkNode.title = "打开帖子"; openLinkNode.setAttribute("aria-label", "打开帖子"); openLinkNode.innerHTML = RAIL_ICONS.external; const collapseButton = document.createElement("button"); collapseButton.type = "button"; collapseButton.className = "icon"; collapseButton.title = "收起"; collapseButton.setAttribute("aria-label", "收起评论预览"); collapseButton.innerHTML = RAIL_ICONS.collapse; tools.append(cyButton, openLinkNode, collapseButton); head.append(headText, tools); const meta = document.createElement("div"); meta.className = "meta"; const list = document.createElement("ol"); list.className = "list"; const status = document.createElement("div"); status.className = "status"; const footBar = document.createElement("div"); footBar.className = "foot-bar"; const moreButton = document.createElement("button"); moreButton.type = "button"; moreButton.className = "action"; moreButton.textContent = "加载更多"; footBar.append(moreButton); dock.append(head, meta, list, status, footBar); panel.append(tab, dock); shadow.append(style, panel); const state = { mode: "hidden", linkId: "", title: "", total: 0, page: 0, items: [], loading: false, error: "", hasMore: false, loadedGroups: 0, seq: 0 }; let hideCy = readHideCyPreference(); const updateCyButton = () => { cyButton.setAttribute("aria-pressed", String(hideCy)); cyButton.title = hideCy ? "显示 CY 评论" : "屏蔽 CY 评论"; cyButton.setAttribute("aria-label", cyButton.title); }; cyButton.addEventListener("click", () => { hideCy = !hideCy; setHideCyPreference(hideCy); updateCyButton(); render(); }); document.addEventListener(HIDE_CY_EVENT, () => { const next = readHideCyPreference(); if (next === hideCy) return; hideCy = next; updateCyButton(); render(); }); const applyMode = () => { host.dataset["state"] = state.mode; tab.setAttribute("aria-expanded", String(state.mode === "open")); if (document.body) document.body.classList.toggle("heybox-up-rail-open", state.mode === "open"); }; const createEmoji = (id) => { const node = document.createElement("span"); node.className = "emoji"; node.setAttribute("role", "img"); node.setAttribute("aria-label", "表情"); const column = Number(id) % 10 - 1; const row = Math.floor(Number(id) / 10) - 1; node.style.backgroundImage = 'url("' + RAIL_EMOJI_SPRITE + '")'; node.style.backgroundPosition = "-" + column * RAIL_EMOJI_CELL + "px -" + row * RAIL_EMOJI_CELL + "px"; return node; }; const createImage = (src) => { const node = document.createElement("img"); node.className = "pic"; node.alt = ""; node.loading = "lazy"; node.referrerPolicy = "no-referrer"; node.src = src; return node; }; const fillBody = (node, segments) => { if (segments.length === 0) { node.textContent = "(图片或表情评论)"; return; } for (const segment of segments) { if (segment.type === "text") node.append(document.createTextNode(segment.text)); else if (segment.type === "emoji") node.append(createEmoji(segment.id)); else node.append(createImage(segment.src)); } }; const createQuote = (quote) => { const node = document.createElement("div"); node.className = "quote"; const name = document.createElement("span"); name.className = "quote-name"; name.textContent = quote.name + ":"; node.append(name); fillBody(node, quote.body); return node; }; const createItem = (comment) => { const item = document.createElement("li"); item.className = "item"; item.setAttribute("role", "listitem"); const avatar = document.createElement("img"); avatar.className = "avatar"; avatar.alt = ""; avatar.loading = "lazy"; avatar.referrerPolicy = "no-referrer"; if (comment.avatar !== "") avatar.src = comment.avatar; const body = document.createElement("div"); body.className = "body"; const who = document.createElement("div"); who.className = "who"; const name = document.createElement("span"); name.className = "name"; name.textContent = comment.name; who.append(name); if (comment.createAt > 0) { const time = document.createElement("span"); time.className = "time"; time.textContent = formatRailAge(comment.createAt); who.append(time); } const text = document.createElement("p"); text.className = "text"; fillBody(text, comment.body); const foot = document.createElement("div"); foot.className = "foot"; if (comment.likes > 0) { const likes = document.createElement("span"); likes.className = "likes"; likes.textContent = "赞 " + comment.likes; foot.append(likes); } if (comment.replies > 0) { const replies = document.createElement("span"); replies.className = "replies"; replies.textContent = comment.replies + " 条回复"; foot.append(replies); } body.append(who, text); if (foot.childElementCount > 0) body.append(foot); const quotes = hideCy ? comment.quotes.filter((quote) => quote.isCy !== true) : comment.quotes; if (quotes.length > 0) { const sub = document.createElement("div"); sub.className = "sub"; sub.append(...quotes.map(createQuote)); body.append(sub); } item.append(avatar, body); return item; }; const render = () => { const visible = hideCy ? state.items.filter((item) => item.isCy !== true) : state.items; const hiddenCy = state.items.length - visible.length; titleNode.textContent = state.title !== "" ? state.title : "评论"; openLinkNode.href = state.linkId !== "" ? LINK_HREF_PREFIX + state.linkId : "#"; openLinkNode.hidden = state.linkId === ""; const loadedText = visible.length > 0 ? " · 已加载 " + visible.length + " 条" : ""; const cyText = hiddenCy > 0 ? " · 已屏蔽 " + hiddenCy + " 条 CY" : ""; if (state.error !== "") { meta.textContent = ""; } else if (state.total > 0) { meta.textContent = "共 " + state.total + " 条评论" + loadedText + cyText; } else { meta.textContent = (visible.length > 0 ? "已加载 " + visible.length + " 条" : "") + cyText; } list.replaceChildren(...visible.map(createItem)); list.hidden = visible.length === 0; if (state.error !== "") { status.textContent = ""; const label = document.createElement("span"); label.textContent = state.error + " "; const link = document.createElement("a"); link.href = LINK_HREF_PREFIX + state.linkId; link.textContent = "打开帖子"; link.addEventListener("click", (event) => event.stopPropagation()); status.append(label, link); status.classList.add("retry"); status.hidden = false; } else if (state.loading && state.items.length === 0) { status.classList.remove("retry"); status.textContent = "正在加载评论…"; status.hidden = false; } else if (state.linkId === "") { status.classList.remove("retry"); status.textContent = "这里会直接显示信息流首条帖子的评论;点其他帖子的评论数字可切换。"; status.hidden = false; } else if (visible.length === 0) { status.classList.remove("retry"); status.textContent = hiddenCy > 0 ? "已屏蔽全部 " + hiddenCy + " 条 CY 评论" : "这条帖子还没有评论"; status.hidden = false; } else { status.classList.remove("retry"); status.hidden = true; } moreButton.hidden = state.linkId === "" || !state.hasMore; moreButton.textContent = state.loading ? "加载中…" : "加载更多"; footBar.hidden = moreButton.hidden; }; const load = async (page) => { const seq = ++state.seq; state.loading = true; state.error = ""; render(); loadController?.abort(); loadController = new AbortController(); try { const data = await fetchRailPage(state.linkId, page, { signal: loadController.signal }); if (seq !== state.seq) return; const seen = new Set(state.items.map((item) => item.id)); const fresh = data.items.filter((item) => item.id === "" || !seen.has(item.id)); state.items = page === 1 ? fresh : state.items.concat(fresh); state.page = page; const progress = resolveCommentPageProgress({ rawGroupCount: data.items.length, freshGroupCount: fresh.length, previouslyLoadedGroups: page === 1 ? 0 : state.loadedGroups, floorTotal: data.floorTotal }); state.loadedGroups = progress.loadedGroups; state.hasMore = progress.hasMore; if (data.total > 0) state.total = data.total; if (data.title !== "" && state.title === "") state.title = data.title; } catch (error) { if (isAbortError(error)) return; if (seq !== state.seq) return; const message = error instanceof Error && error.message ? error.message : "未知错误"; state.error = /show_captcha|captcha/i.test(message) ? "评论需要验证,请稍后重试,或打开帖子查看。" : "评论加载失败:" + message + ",可打开帖子查看。"; state.hasMore = false; } finally { if (seq === state.seq) { state.loading = false; loadController = null; render(); } } }; const open = (linkId) => { const next = String(linkId === void 0 || linkId === null ? "" : linkId); if (next === "") return; state.mode = "open"; applyMode(); if (next === state.linkId) return; loadController?.abort(); loadController = null; state.seq += 1; state.loading = false; state.linkId = next; state.title = ""; state.total = 0; state.page = 0; state.items = []; state.hasMore = false; state.loadedGroups = 0; state.error = ""; render(); void load(1); }; const collapse = () => { state.mode = state.linkId === "" ? "hidden" : "collapsed"; applyMode(); }; const setActive = (active) => { if (!active) { if (state.mode === "hidden" && !state.loading && loadController === null) return; loadController?.abort(); loadController = null; state.seq += 1; state.loading = false; state.mode = "hidden"; applyMode(); return; } if (state.mode !== "hidden") return; state.mode = "collapsed"; applyMode(); }; tab.addEventListener("click", () => { state.mode = "open"; applyMode(); if (state.linkId === "") { const anchor = document.querySelector("#page-bbs-community .bbs-home__content-list a[href*='/app/bbs/link/']"); const match = anchor ? /link\/(\d+)/.exec(anchor.getAttribute("href") || "") : null; if (match) { open(match[1]); return; } } render(); }); collapseButton.addEventListener("click", collapse); status.addEventListener("click", () => { if (state.error === "" || state.loading) return; void load(1); }); moreButton.addEventListener("click", () => { if (state.loading || state.linkId === "") return; void load(state.page + 1); }); updateCyButton(); applyMode(); render(); return { host, open, collapse, setActive, isOpen: () => state.mode === "open", getLinkId: () => state.linkId }; } function watchCommentRail() { if (!document.body) return; const rail = createCommentRail(); document.body.append(rail.host); const isFeedPage = () => readPageRoute() === PAGE_ROUTE.FEED && document.querySelector("#page-bbs-community .bbs-home__content-list") !== null; const readLinkId = (href) => { const match = /link\/(\d+)/.exec(href || ""); return match ? match[1] : ""; }; const readFirstFeedLinkId = () => { const anchor = document.querySelector("#page-bbs-community .bbs-home__content-list a[href*='/app/bbs/link/']"); if (!anchor) return ""; return readLinkId(anchor.getAttribute("href")); }; const readCardLinkId = (element) => { const anchor = element.closest('a[href*="/app/bbs/link/"]'); if (anchor) return readLinkId(anchor.getAttribute("href")); const card = element.closest(".bbs-home__content-item, .content-list__item, li"); if (!card) return ""; const inner = card.querySelector('a[href*="/app/bbs/link/"]'); return inner ? readLinkId(inner.getAttribute("href")) : ""; }; const isEnabled = () => readCommentPreviewPreference(); document.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; const trigger = target.closest(RAIL_TRIGGER_SELECTOR); if (!trigger) return; const linkId = readCardLinkId(trigger); if (linkId === "") return; if (!isEnabled()) return; if (!isFeedPage()) return; event.preventDefault(); event.stopPropagation(); rail.open(linkId); }, true); let hoverTimer = 0; let hoverId = ""; const cancelHover = () => { if (hoverTimer !== 0) window.clearTimeout(hoverTimer); hoverTimer = 0; hoverId = ""; }; document.addEventListener("pointerover", (event) => { if (!isEnabled() || !rail.isOpen()) return; const target = event.target; if (!(target instanceof Element)) return; const card = target.closest("#page-bbs-community .bbs-home__content-item"); if (!card) return; const linkId = readCardLinkId(card); if (linkId === "" || linkId === hoverId) return; cancelHover(); hoverId = linkId; hoverTimer = window.setTimeout(() => { hoverTimer = 0; if (!rail.isOpen() || rail.getLinkId() === linkId) return; rail.open(linkId); }, 600); }, true); document.addEventListener("pointerout", (event) => { if (hoverTimer === 0) return; const target = event.target; if (!(target instanceof Element)) return; const card = target.closest("#page-bbs-community .bbs-home__content-item"); if (!card) return; const related = event.relatedTarget; if (related instanceof Node && card.contains(related)) return; cancelHover(); }, true); document.addEventListener("pointerdown", cancelHover, true); document.addEventListener("scroll", cancelHover, true); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || !rail.isOpen()) return; event.preventDefault(); rail.collapse(); }); let autoOpened = false; const sync = () => { if (!isEnabled()) { autoOpened = false; rail.setActive(false); rail.host.remove(); return; } const active = isFeedPage(); rail.setActive(active); if (!active) { autoOpened = false; rail.host.remove(); return; } if (rail.host.parentNode !== document.body) document.body.append(rail.host); if (!autoOpened && !rail.isOpen()) { const linkId = readFirstFeedLinkId(); if (linkId !== "") { autoOpened = true; rail.open(linkId); } } }; document.addEventListener(COMMENT_PREVIEW_EVENT, sync); registerPageSync(sync, 200, [PAGE_ROUTE.FEED], "feed"); sync(); } // scripts/heybox-up/src/ui/comment-sort.ts var COMMENT_SORTS = [ { id: "default", label: "默认" }, { id: "time", label: "时间" }, { id: "likes", label: "点赞" }, { id: "author", label: "作者" } ]; var COMMENT_SORT_HOST_ID = "heybox-up-comment-sort"; var COMMENT_AGE_UNITS = { 秒: 1, 分钟: 60, 小时: 3600, 天: 86400 }; var COMMENT_SORT_CSS = ` :host { display: block; margin: 0 !important; padding: 9px 12px !important; border-bottom: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)) !important; background: var(--heybox-up-surface, #fff) !important; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif !important; } .bar { display: flex; align-items: center; gap: 8px; } .group { display: inline-flex; gap: 2px; padding: 2px; border-radius: 999px; background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); } .spacer { flex: 1 1 auto; } button { padding: 4px 10px; border: 0; border-radius: 999px; background: transparent; color: var(--heybox-up-muted, #6b7280); font: inherit; cursor: pointer; transition: color .12s ease, background-color .12s ease; } button:hover { color: var(--heybox-up-text, #14191e); } button:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: 1px; } .group button[aria-pressed="true"] { background: var(--heybox-up-surface, #fff); color: var(--heybox-up-text, #14191e); font-weight: 600; box-shadow: 0 1px 2px rgba(16, 24, 40, .14); } .cy { padding: 4px 9px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); } .cy[aria-pressed="true"] { border-color: transparent; background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); color: var(--heybox-up-accent, #6f8db3); font-weight: 600; } `; function parseCommentAge(text) { const value = text.trim(); if (value === "" || value === "刚刚") return value === "刚刚" ? 0 : Number.POSITIVE_INFINITY; if (value === "昨天") return 86400; const relative = /^(\d+)\s*(秒|分钟|小时|天)前$/.exec(value); if (relative) { const unit = COMMENT_AGE_UNITS[relative[2]]; return Number(relative[1]) * (unit ?? 0); } const parsed = Date.parse(value.replace(/-/g, "/")); return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : (Date.now() - parsed) / 1e3; } function applyCommentSort(tree, mode) { const list = tree.querySelector(".link-comment__list"); if (!list) return; const items = []; for (const node of Array.from(list.children)) { if (node instanceof HTMLElement && node.classList.contains("link-comment__comment-item")) items.push(node); } if (mode === "default") { for (const item of items) item.style.order = ""; return; } const authorName = (document.querySelector("#page-bbs-link .link-user__username")?.textContent || "").trim(); const keyed = items.map((item, index) => { const likes = Number((item.querySelector(".like-box__cnt")?.textContent || "0").trim()) || 0; const age = parseCommentAge(item.querySelector(".info-box__create-time")?.textContent || ""); const name = (item.querySelector(".info-box__username")?.textContent || "").trim(); return { item, index, likes, age, isAuthor: authorName !== "" && name === authorName }; }); keyed.sort((a, b) => { if (mode === "likes") return b.likes - a.likes || a.index - b.index; if (mode === "time") return a.age - b.age || a.index - b.index; if (mode === "author") return Number(b.isAuthor) - Number(a.isAuthor) || a.index - b.index; return a.index - b.index; }); keyed.forEach((entry, index) => { entry.item.style.order = String(index); }); } function createCommentSort(tree, mode = "default", isHideCy = () => false, onToggleCy = () => { }) { const host = document.createElement("div"); host.id = COMMENT_SORT_HOST_ID; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = COMMENT_SORT_CSS; const bar = document.createElement("div"); bar.className = "bar"; const group = document.createElement("div"); group.className = "group"; group.setAttribute("role", "group"); group.setAttribute("aria-label", "评论排序"); bar.append(group); let current = mode; const buttons = COMMENT_SORTS.map((sort) => { const button = document.createElement("button"); button.type = "button"; button.textContent = sort.label; button.dataset["sort"] = sort.id; button.setAttribute("aria-pressed", String(sort.id === current)); button.addEventListener("click", () => { current = sort.id; for (const node of buttons) node.setAttribute("aria-pressed", String(node.dataset["sort"] === current)); applyCommentSort(tree, current); }); group.append(button); return button; }); const spacer = document.createElement("span"); spacer.className = "spacer"; const cyButton = document.createElement("button"); cyButton.type = "button"; cyButton.className = "cy"; cyButton.textContent = "屏蔽 CY"; cyButton.setAttribute("aria-pressed", String(isHideCy())); cyButton.title = isHideCy() ? "显示 CY 评论" : "屏蔽 CY 评论"; cyButton.addEventListener("click", () => onToggleCy()); bar.append(spacer, cyButton); shadow.append(style, bar); tree.prepend(host); return host; } function watchCommentSort() { if (!document.body) return; let host = null; let mode = "default"; let cyLinkId = ""; let cyPage = 0; let cyDone = false; let cyLoading = false; let cyIds = /* @__PURE__ */ new Set(); let cyScanned = /* @__PURE__ */ new Set(); const getLinkId = () => { const match = /link\/(\d+)/.exec(location.pathname); return match ? match[1] : ""; }; const toggleCy = () => { setHideCyPreference(!readHideCyPreference()); }; const updateCyButton = () => { const button = host?.shadowRoot?.querySelector("button.cy"); if (!(button instanceof HTMLButtonElement)) return; const hideCy = readHideCyPreference(); button.setAttribute("aria-pressed", String(hideCy)); button.title = hideCy ? "显示 CY 评论" : "屏蔽 CY 评论"; }; const markCy = () => { const tree = document.querySelector("#page-bbs-link .link-comment"); if (!tree) return; let count = 0; const counted = /* @__PURE__ */ new Set(); for (const node of tree.querySelectorAll("[data-comment-id]")) { const id = node.getAttribute("data-comment-id") || ""; if (cyIds.has(id)) { if (node.getAttribute("data-heybox-up-cy") !== "1") node.setAttribute("data-heybox-up-cy", "1"); if (!counted.has(id)) { counted.add(id); count += 1; } } else if (node.hasAttribute("data-heybox-up-cy")) { node.removeAttribute("data-heybox-up-cy"); } } const button = host?.shadowRoot?.querySelector("button.cy"); if (!(button instanceof HTMLButtonElement)) return; const hidden = readHideCyPreference() ? count : 0; button.textContent = hidden > 0 ? "屏蔽 CY (" + hidden + ")" : "屏蔽 CY"; }; const pullCyIds = async () => { if (cyLoading || cyDone) return; const linkId = getLinkId(); const tree = document.querySelector("#page-bbs-link .link-comment"); if (linkId === "" || !tree) return; const hasUnknown = () => Array.from(tree.querySelectorAll("[data-comment-id]")).some((node) => { const id = node.getAttribute("data-comment-id") || ""; return id !== "" && !cyScanned.has(id); }); if (!hasUnknown()) return; cyLoading = true; try { while (!cyDone && cyPage < 10 && getLinkId() === linkId) { const data = await fetchRailPage(linkId, cyPage + 1); cyPage += 1; let added = 0; for (const group of data.groups) { const groupRecord = asRecord(group); const rawList = groupRecord === null ? void 0 : groupRecord["comment"]; const list = Array.isArray(rawList) ? rawList : []; for (const raw of list) { const record = asRecord(raw); const id = String((record === null ? "" : record["commentid"] || record["comment_id"]) || ""); if (id !== "" && !cyScanned.has(id)) { cyScanned.add(id); added += 1; } if (id !== "" && isCyComment(raw)) cyIds.add(id); } } if (added === 0) cyDone = true; if (!hasUnknown()) break; } if (cyPage >= 10) cyDone = true; } catch { cyDone = true; } finally { cyLoading = false; markCy(); } }; const sync = () => { if (!location.pathname.startsWith(LINK_HREF_PREFIX)) { host = null; mode = "default"; return; } syncHideCyPreference(); const tree = document.querySelector("#page-bbs-link .link-comment"); if (!tree) return; const linkId = getLinkId(); if (linkId !== cyLinkId) { cyLinkId = linkId; cyPage = 0; cyDone = false; cyIds = /* @__PURE__ */ new Set(); cyScanned = /* @__PURE__ */ new Set(); } const active = host?.shadowRoot?.querySelector('button[data-sort][aria-pressed="true"]'); if (active instanceof HTMLElement) mode = active.dataset["sort"] || "default"; if (!host || !host.isConnected || host.parentElement !== tree) { host = createCommentSort(tree, mode, readHideCyPreference, toggleCy); } applyCommentSort(tree, mode); updateCyButton(); markCy(); void pullCyIds(); }; registerPageSync(sync, 250, [PAGE_ROUTE.ARTICLE], "article"); document.addEventListener(HIDE_CY_EVENT, () => { updateCyButton(); markCy(); }); sync(); } // scripts/heybox-up/src/ui/navigation.ts var NAV_ID = "heybox-up-nav"; var NAV_STYLE_ID = "heybox-up-nav-style"; var NAV_STATIC_CLASS = "heybox-up-nav-static"; var CY_HASH = "#heybox-up-cy"; var NAV_ITEMS = [ { id: "community", label: "社区", href: "/app/bbs/home" }, { id: "favour", label: "收藏", href: "/app/user/favour/content" }, { id: "article", label: "文章", href: "/creator/content_management/home?article_type=all" } ]; var NAV_GLYPHS = { community: { paths: ["M20.5 11.6a7.9 7.9 0 0 1-8.4 7.9 9 9 0 0 1-2.8-.4L4.5 20.5l1.3-4.1a7.8 7.8 0 0 1-1.3-4.8A7.9 7.9 0 0 1 12.4 3.7a7.9 7.9 0 0 1 8.1 7.9Z"], circles: [ 'cx="8.8" cy="12" r=".9" fill="currentColor" stroke="none"', 'cx="12.4" cy="12" r=".9" fill="currentColor" stroke="none"', 'cx="16" cy="12" r=".9" fill="currentColor" stroke="none"' ] }, favour: { paths: ["M6.5 4h11a1 1 0 0 1 1 1v15l-6.5-4.2L5.5 20V5a1 1 0 0 1 1-1Z"] }, article: { paths: ["M6 3h8l4 4v14H6z", "M14 3v5h5", "M9 13h6", "M9 16.5h6"] } }; var NAV_SVG_OPEN = ''; function navIconMarkup(id, animated) { const glyph = NAV_GLYPHS[id]; if (glyph === void 0) return ""; const paths = glyph.paths.map((d) => { if (!animated) return ''; return ''; }).join(""); const circles = (glyph.circles ?? []).map((attrs) => "").join(""); const variant = animated ? " hbu-ico-anim" : ""; return '"; } function buildNavIconMap(animated) { const map = {}; for (const id of Object.keys(NAV_GLYPHS)) map[id] = navIconMarkup(id, animated); return map; } var NAV_STATIC_ICONS = buildNavIconMap(false); var NAV_DYNAMIC_ICONS = buildNavIconMap(true); function hasNavGlyph(id) { return Object.prototype.hasOwnProperty.call(NAV_GLYPHS, id); } function navIconMarkupFor(id) { return navIconMarkup(id, !document.documentElement.classList.contains(NAV_STATIC_CLASS)); } var NAV_CSS = ` .nav-links > .nav-link { display: none !important; } #${NAV_ID} { display: flex; align-items: stretch; height: 64px; gap: 2px; margin-left: 2px; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .hbu-nav-link { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 100%; padding: 0 12px; border-radius: 8px; color: var(--nav-link-color, var(--heybox-up-muted, #8c9198)); text-decoration: none; white-space: nowrap; transition: color .18s ease; } .hbu-nav-link::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 11px; height: 2px; border-radius: 2px; background: var(--heybox-up-accent, #6f8db3); transform: scaleX(0); transform-origin: 50% 50%; transition: transform .2s ease; } .hbu-nav-link:hover { color: var(--nav-link-active-color, var(--heybox-up-text, #14191e)); } .hbu-nav-link:hover::after, .hbu-nav-link.is-active::after { transform: scaleX(1); } .hbu-nav-link.is-active { color: var(--nav-link-active-color, var(--heybox-up-text, #14191e)); font-weight: 600; } .hbu-nav-link:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: -2px; } .hbu-nav-icon { display: inline-flex; width: 18px; height: 18px; flex: none; align-items: center; justify-content: center; transform-origin: 50% 60%; transition: transform .18s cubic-bezier(.22, .8, .3, 1), color .18s ease; } .hbu-nav-icon svg { display: block; width: 100%; height: 100%; flex: none; } .hbu-nav-link:focus-visible .hbu-nav-icon.hbu-ico-anim { color: var(--nav-link-active-color, var(--heybox-up-text, #14191e)); transform: translateY(-2px) scale(1.14); } .hbu-nav-link[data-nav="article"]:focus-visible .hbu-nav-icon.hbu-ico-anim { transform: rotate(-7deg) translateY(-2px) scale(1.1); } /* Dynamic glyphs run on their own: the base copy keeps the outline solid and the flow copy sweeps a short accent dash along the same geometry, forever, with no pointer or focus needed. */ .hbu-ico-anim path.hbu-ico-base { stroke-dasharray: none; stroke-dashoffset: 0; } .hbu-ico-anim path.hbu-ico-flow { stroke: var(--heybox-up-accent, #6f8db3); stroke-dasharray: .3 .7; stroke-dashoffset: 0; opacity: 0; animation: hbu-nav-ico-flow 3.2s ease-in-out infinite; } @keyframes hbu-nav-ico-flow { 0% { stroke-dashoffset: 0; opacity: 0; } 10% { stroke-dashoffset: -.1; opacity: .95; } 55% { stroke-dashoffset: -1; opacity: .95; } 70%, 100% { stroke-dashoffset: -1; opacity: 0; } } /* A very small breath keeps every mounted dynamic icon alive at rest without touching the 18x18 layout box or the hover transform on the wrapper. */ .hbu-nav-icon.hbu-ico-anim svg { transform-origin: 50% 55%; animation: hbu-nav-ico-breathe 3.8s ease-in-out infinite; } @keyframes hbu-nav-ico-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } } /* Per-entry durations and negative delays keep the four icons out of lockstep. */ .hbu-nav-icon.hbu-ico-anim.hbu-ico-community path.hbu-ico-flow { animation-duration: 3.6s; animation-delay: -.7s; } .hbu-nav-icon.hbu-ico-anim.hbu-ico-favour path.hbu-ico-flow { animation-duration: 3s; animation-delay: -1.4s; } .hbu-nav-icon.hbu-ico-anim.hbu-ico-article path.hbu-ico-flow { animation-duration: 3.9s; animation-delay: -2.1s; } .hbu-nav-icon.hbu-ico-anim.hbu-ico-community svg { animation-duration: 4.2s; animation-delay: -.9s; } .hbu-nav-icon.hbu-ico-anim.hbu-ico-favour svg { animation-duration: 3.6s; animation-delay: -1.7s; } .hbu-nav-icon.hbu-ico-anim.hbu-ico-article svg { animation-duration: 4.6s; animation-delay: -2.6s; } .hbu-nav-link:focus-visible .hbu-nav-icon.hbu-ico-anim path.hbu-ico-flow { animation-duration: 1.3s; } .hbu-nav-link:active .hbu-nav-icon.hbu-ico-anim path.hbu-ico-flow { animation-duration: .9s; } /* Touch browsers keep :hover on the tapped link, which would leave the icon enlarged. Gate hover-only motion to devices that can hover with a precise pointer. */ @media (hover: hover) and (pointer: fine) { .hbu-nav-link:hover .hbu-nav-icon.hbu-ico-anim { color: var(--nav-link-active-color, var(--heybox-up-text, #14191e)); transform: translateY(-2px) scale(1.14); } .hbu-nav-link[data-nav="article"]:hover .hbu-nav-icon.hbu-ico-anim { transform: rotate(-7deg) translateY(-2px) scale(1.1); } .hbu-nav-link:hover .hbu-nav-icon.hbu-ico-anim path.hbu-ico-flow { animation-duration: 1.3s; } } .hbu-nav-link:active .hbu-nav-icon.hbu-ico-anim { transform: translateY(0) scale(.9); transition-duration: .08s; } .hbu-nav-link[data-nav="article"]:active .hbu-nav-icon.hbu-ico-anim { transform: translateY(0) rotate(0) scale(.9); } :root.${NAV_STATIC_CLASS} .hbu-nav-icon { animation: none !important; transform: none !important; transition: none !important; } :root.${NAV_STATIC_CLASS} .hbu-nav-icon svg { animation: none !important; transform: none !important; } :root.${NAV_STATIC_CLASS} .hbu-nav-icon path.hbu-ico-flow { display: none !important; } :root.${NAV_STATIC_CLASS} .hbu-nav-icon path { stroke-dasharray: none !important; stroke-dashoffset: 0 !important; opacity: 1 !important; } @media (prefers-reduced-motion: reduce) { .hbu-nav-icon, .hbu-nav-icon svg, .hbu-nav-icon path { animation: none !important; transform: none !important; transition: none !important; } .hbu-nav-icon path { stroke-dasharray: none !important; stroke-dashoffset: 0 !important; opacity: 1 !important; } .hbu-nav-icon path.hbu-ico-flow { display: none !important; } .hbu-nav-link::after { transition: none; } } @media (max-width: 1180px) { .hbu-nav-link { padding: 0 9px; gap: 5px; font-size: 13px; } .hbu-nav-link::after { left: 9px; right: 9px; } } @media (max-width: 940px) { .hbu-nav-link > span:not(.hbu-nav-icon) { display: none; } .hbu-nav-link { padding: 0 10px; } } `; function applyNavMotionPreference(value) { document.documentElement.classList.toggle(NAV_STATIC_CLASS, value === false); syncNavIconVariant(value === true); } function syncNavIconVariant(animated) { for (const link of Array.from(document.querySelectorAll("a[data-nav], a[data-route]"))) { const id = link.dataset["nav"] || link.dataset["route"] || ""; if (!hasNavGlyph(id)) continue; const icon = link.querySelector(".hbu-nav-icon"); if (icon === null) continue; if (icon.classList.contains("hbu-ico-anim") === animated) continue; const markup = navIconMarkup(id, animated); if (markup === "") continue; const template = document.createElement("template"); template.innerHTML = markup; const next = template.content.firstElementChild; if (!(next instanceof HTMLElement)) continue; const heldFocus = icon.contains(document.activeElement); icon.replaceWith(next); if (heldFocus) link.focus({ preventScroll: true }); } } function ensureNavStyle() { ensureStyleElement(NAV_STYLE_ID, NAV_CSS); } function releaseNav() { document.getElementById(NAV_ID)?.remove(); removeStyleElement(NAV_STYLE_ID); document.documentElement.classList.remove(NAV_STATIC_CLASS); navActiveHost = null; navActiveId = ""; } function readNavActiveId() { const path = location.pathname; if (path === "/app/user/favour/content") return "favour"; if (path.startsWith("/creator/")) return "article"; if (path.startsWith("/app/bbs/")) return "community"; return ""; } var navActiveHost = null; var navActiveId = ""; function syncNavActive() { const host = document.getElementById(NAV_ID); if (!host) { navActiveHost = null; navActiveId = ""; return; } const activeId = readNavActiveId(); if (host === navActiveHost && activeId === navActiveId) return; for (const node of Array.from(host.querySelectorAll(".hbu-nav-link"))) { const active = node.dataset["nav"] === activeId; node.classList.toggle("is-active", active); if (active) node.setAttribute("aria-current", "page"); else node.removeAttribute("aria-current"); } navActiveHost = host; navActiveId = activeId; } function scrollToCySection() { const host = document.getElementById("heybox-up-favour"); if (!host) return false; host.scrollIntoView({ behavior: "smooth", block: "start" }); return true; } function readSiteRouter() { const root = document.querySelector("#app"); const app = asRecord(root === null ? null : Reflect.get(root, "__vue_app__")); const config = app === null ? null : asRecord(app["config"]); const properties = config === null ? null : asRecord(config["globalProperties"]); const router = properties === null ? null : asRecord(properties["$router"]); if (router === null) return null; const push = router["push"]; if (typeof push !== "function") return null; return { push: (path) => Reflect.apply(push, router, [path]) }; } function readThenable(value) { if (value === null || value === void 0) return null; if (typeof value !== "object" && typeof value !== "function") return null; const then = Reflect.get(value, "then"); if (typeof then !== "function") return null; return value; } function navigateToPath(path) { const router = readSiteRouter(); if (router) { try { const pending = router.push(path); const thenable = readThenable(pending); if (thenable !== null) { return Promise.resolve(thenable).then( (result) => !(result !== null && typeof result === "object" && typeof Reflect.get(result, "type") === "number"), () => false ); } return Promise.resolve(true); } catch { } } const anchor = document.createElement("a"); anchor.href = path; anchor.style.display = "none"; document.body.append(anchor); anchor.click(); anchor.remove(); return Promise.resolve(true); } function handleNavClick(event) { const target = event.target; if (!(target instanceof Element)) return; const link = target.closest("a.hbu-nav-link"); if (!link) return; if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; const id = link.getAttribute("data-nav"); if (id === "favour" && location.pathname === "/app/user/favour/content" && location.hash !== "") { event.preventDefault(); history.pushState(null, "", "/app/user/favour/content"); window.scrollTo({ top: 0, behavior: "smooth" }); syncNavActive(); return; } event.preventDefault(); const href = link.getAttribute("href"); if (href !== null && href !== "") navigateToPath(href); } function mountTopNav() { if (!document.body) return; const current = document.getElementById(NAV_ID); if (current?.parentElement?.classList.contains("nav-links") && current.parentElement.firstElementChild === current) { syncNavIconVariant(!document.documentElement.classList.contains(NAV_STATIC_CLASS)); syncNavActive(); return; } const links = document.querySelector(".nav .nav-links") || document.querySelector(".nav-links"); if (!links) return; ensureNavStyle(); let host = current; if (!host) { host = document.createElement("div"); host.id = NAV_ID; host.setAttribute("role", "navigation"); host.setAttribute("aria-label", "站内导航"); const animated = !document.documentElement.classList.contains(NAV_STATIC_CLASS); host.innerHTML = NAV_ITEMS.map( (item) => '' + navIconMarkup(item.id, animated) + "" + item.label + "" ).join(""); host.addEventListener("click", handleNavClick); } if (host.parentNode !== links || links.firstElementChild !== host) links.prepend(host); syncNavIconVariant(!document.documentElement.classList.contains(NAV_STATIC_CLASS)); syncNavActive(); } function watchTopNav() { if (!document.body) return; const sync = () => { if (isOfficialHomePath(location.pathname)) { releaseNav(); return; } mountTopNav(); syncNavActive(); }; window.addEventListener("hashchange", syncNavActive); window.addEventListener("popstate", syncNavActive); registerPageSync(sync, 300, ADAPTED_ROUTES, "chrome"); sync(); } // scripts/heybox-up/src/ui/community-search.ts var COMMUNITY_SEARCH_API_PATH = "/bbs/app/api/general/search/v1"; var COMMUNITY_SEARCH_ID = "heybox-up-community-search"; var COMMUNITY_SEARCH_STYLE_ID = "heybox-up-community-search-style"; var COMMUNITY_SEARCH_ACTIVE_CLASS = "heybox-up-cs-active"; var COMMUNITY_SEARCH_PAGE_SIZE = 30; var COMMUNITY_SEARCH_ICONS = { search: '' }; var COMMUNITY_SEARCH_CSS = ` #heybox-up-community-search { display: block; margin: 14px 0 12px; font: 13px/1.55 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; color: var(--heybox-up-text, #14191e); -webkit-font-smoothing: antialiased; } #heybox-up-community-search *, #heybox-up-community-search *::before, #heybox-up-community-search *::after { box-sizing: border-box; } #heybox-up-community-search .bar { display: flex; align-items: center; gap: 10px; height: 42px; margin: 0; padding: 0 6px 0 13px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: var(--heybox-up-radius-card, 14px); background: var(--heybox-up-surface, #fff); box-shadow: var(--heybox-up-shadow-1, none); transition: border-color .16s ease, box-shadow .16s ease; } #heybox-up-community-search .bar:focus-within { border-color: var(--heybox-up-accent, #6f8db3); box-shadow: 0 0 0 3px rgba(111, 141, 179, .16); } #heybox-up-community-search .icon { display: inline-flex; flex: none; color: var(--heybox-up-muted, #6b7280); } #heybox-up-community-search .icon svg { width: 16px; height: 16px; } #heybox-up-community-search .scope { flex: none; max-width: 34%; height: 22px; padding: 0 9px; border-radius: 999px; background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); color: var(--heybox-up-muted, #6b7280); font-size: 12px; line-height: 22px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #heybox-up-community-search input.q { flex: 1 1 auto; min-width: 0; height: 30px; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: 14px; line-height: 30px; outline: none; box-shadow: none; appearance: none; } #heybox-up-community-search input.q::placeholder { color: var(--heybox-up-muted, #6b7280); } #heybox-up-community-search button.go { flex: none; height: 30px; margin: 0; padding: 0 15px; border: 0; border-radius: 999px; background: var(--heybox-up-accent, #6f8db3); color: var(--heybox-up-on-accent, #fff); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: filter .16s ease; } #heybox-up-community-search button.go:hover { filter: brightness(1.06); } #heybox-up-community-search button.go:focus-visible, #heybox-up-community-search .chip:focus-visible, #heybox-up-community-search .more:focus-visible, #heybox-up-community-search .close:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: 2px; } #heybox-up-community-search .panel { margin-top: 10px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: var(--heybox-up-radius-card, 14px); background: var(--heybox-up-surface, #fff); box-shadow: var(--heybox-up-shadow-1, none); overflow: hidden; } #heybox-up-community-search .panel[hidden] { display: none !important; } #heybox-up-community-search .head { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); } #heybox-up-community-search .head-text { flex: 1 1 auto; min-width: 0; } #heybox-up-community-search .head-title { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.5; color: var(--heybox-up-text, #14191e); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #heybox-up-community-search .head-meta { margin: 2px 0 0; font-size: 12px; line-height: 1.5; color: var(--heybox-up-muted, #6b7280); } #heybox-up-community-search .head-actions { flex: none; display: flex; align-items: center; gap: 6px; } #heybox-up-community-search .ghost, #heybox-up-community-search .close { height: 28px; padding: 0 11px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: 999px; background: transparent; color: var(--heybox-up-text-2, #3c4249); font: inherit; font-size: 12px; line-height: 26px; text-decoration: none; white-space: nowrap; cursor: pointer; transition: background .16s ease, border-color .16s ease, color .16s ease; } #heybox-up-community-search .ghost:hover, #heybox-up-community-search .close:hover { border-color: var(--heybox-up-accent, #6f8db3); background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); color: var(--heybox-up-text, #14191e); } #heybox-up-community-search .filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); } #heybox-up-community-search .filters[hidden] { display: none !important; } #heybox-up-community-search .chip { height: 26px; margin: 0; padding: 0 11px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: 999px; background: transparent; color: var(--heybox-up-text-2, #3c4249); font: inherit; font-size: 12px; line-height: 24px; cursor: pointer; transition: color .16s ease, border-color .16s ease, background .16s ease; } #heybox-up-community-search .chip:hover { border-color: var(--heybox-up-accent, #6f8db3); } #heybox-up-community-search .chip[aria-pressed="true"] { border-color: var(--heybox-up-accent, #6f8db3); background: rgba(111, 141, 179, .14); color: var(--heybox-up-accent, #6f8db3); font-weight: 600; } #heybox-up-community-search .item { display: block; padding: 15px 16px; border-bottom: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); color: inherit; text-decoration: none; cursor: pointer; transition: background .14s ease; } #heybox-up-community-search .item:last-child { border-bottom: 0; } #heybox-up-community-search .item:hover { background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); } #heybox-up-community-search .item-thumb { float: right; width: 88px; height: 66px; margin: 2px 0 6px 14px; border-radius: 8px; object-fit: cover; background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); } #heybox-up-community-search .item-title { margin: 0 0 4px; font-size: 15px; font-weight: 600; line-height: 1.45; color: var(--heybox-up-text, #14191e); } #heybox-up-community-search .item-desc { margin: 0 0 8px; font-size: 13px; line-height: 1.62; color: var(--heybox-up-text-2, #3c4249); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } #heybox-up-community-search .item-meta { margin: 0; font-size: 12px; line-height: 1.5; color: var(--heybox-up-muted, #6b7280); } #heybox-up-community-search .empty { margin: 0; padding: 30px 18px; text-align: center; font-size: 13px; line-height: 1.7; color: var(--heybox-up-muted, #6b7280); } #heybox-up-community-search .foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); } #heybox-up-community-search .foot[hidden] { display: none !important; } #heybox-up-community-search .more { flex: none; height: 30px; margin: 0; padding: 0 14px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: 999px; background: transparent; color: var(--heybox-up-text-2, #3c4249); font: inherit; font-size: 12px; cursor: pointer; } #heybox-up-community-search .more[hidden] { display: none !important; } #heybox-up-community-search .more:hover:not(:disabled) { border-color: var(--heybox-up-accent, #6f8db3); color: var(--heybox-up-text, #14191e); } #heybox-up-community-search .more:disabled { opacity: .45; cursor: default; } #heybox-up-community-search .status { margin: 0; font-size: 12px; line-height: 1.5; color: var(--heybox-up-muted, #6b7280); text-align: right; } /* While the result panel is open the site filter row and feed hide, so two lists never stack. */ #page-topic-link.heybox-up-cs-active .topic-link__filter-row, #page-topic-link.heybox-up-cs-active .topic-link__list { display: none !important; } @media (max-width: 720px) { #heybox-up-community-search { margin: 10px 0; } #heybox-up-community-search .scope { display: none; } #heybox-up-community-search .head { flex-wrap: wrap; } #heybox-up-community-search .item-thumb { width: 72px; height: 54px; } } `; function ensureCommunitySearchStyle() { ensureStyleElement(COMMUNITY_SEARCH_STYLE_ID, COMMUNITY_SEARCH_CSS); } function readTopicContext() { const match = /^\/app\/topic\/link\/(\d+)/.exec(location.pathname); if (match === null) return null; const topicId = match[1]; if (topicId === void 0) return null; const indicator = document.querySelector("#page-topic-link .topic-aside__topic-indicator .hot-topic-item__name"); if (indicator !== null) { const indicatorName = normalizeRailText(indicator.textContent).slice(0, 24); if (indicatorName !== "") return { topicId, name: indicatorName }; } const header = document.querySelector("#page-topic-link .topic-link__header"); let name = ""; if (header !== null) { const heading = header.querySelector("h1, h2, [class*='name'], [class*='title']"); if (heading !== null) name = normalizeRailText(heading.textContent).slice(0, 24); } if (name === "") name = normalizeRailText((document.title || "").replace(/[-_|].*$/, "")).slice(0, 24); return { topicId, name: name === "" ? "本社区" : name }; } function buildCommunitySearchUrl(topicId, keyword, offset, timeRange, signed) { const query = new URLSearchParams({ os_type: "web", app: "heybox", client_type: "web", version: "999.0.4", web_version: "2.5", x_client_type: "web", x_app: "heybox_website", x_os_type: "Windows", device_info: "Chrome", q: keyword, search_type: "link", is_pull_down: "0", offset: String(offset), limit: String(COMMUNITY_SEARCH_PAGE_SIZE), dw: "660", topic_id: String(topicId) }); if (timeRange !== "") query.set("time_range", timeRange); if (signed === true) { const time = Math.floor(Date.now() / 1e3); const nonce = md5Hex(String(time) + String(Math.random())).toUpperCase(); query.set("hkey", createSignature(COMMUNITY_SEARCH_API_PATH, time, nonce)); query.set("_time", String(time)); query.set("nonce", nonce); } return "https://api.xiaoheihe.cn" + COMMUNITY_SEARCH_API_PATH + "?" + query.toString(); } async function fetchCommunitySearchPage(url, signal) { let response; try { response = await fetchWithTimeout(url, { credentials: "include", headers: { accept: "application/json" }, signal }); } catch (error) { if (isAbortError(error)) return { ok: false, message: "搜索已取消", aborted: true }; return { ok: false, message: error instanceof Error && error.message !== "" ? error.message : "网络请求失败,请稍后重试" }; } if (response.ok !== true) return { ok: false, message: "接口返回 HTTP " + response.status }; let raw; try { raw = await response.json(); } catch { return { ok: false, message: "接口返回非 JSON 内容,可能触发站点风控" }; } const data = asRecord(raw); if (data === null || data["status"] !== "ok") { const status = data !== null && typeof data["status"] === "string" ? data["status"] : ""; const rawMessage = data === null ? "" : data["msg"]; const message = typeof rawMessage === "string" && rawMessage !== "" ? rawMessage : status; return { ok: false, message: message === "" ? "接口返回失败状态" : message }; } const result = asRecord(data["result"]) ?? {}; const items = Array.isArray(result["items"]) ? result["items"] : []; const linkCount = items.filter((entry) => { const record = asRecord(entry); if (record === null) return false; const type = record["type"]; return typeof type !== "string" || type === "" || type === "link"; }).length; const rawRanges = Array.isArray(result["time_range_list"]) ? result["time_range_list"] : []; const timeRanges = rawRanges.map((entry) => asRecord(entry)).filter((entry) => entry !== null).filter((entry) => typeof entry["value"] === "string" && typeof entry["name"] === "string").map((entry) => ({ value: String(entry["value"]), name: String(entry["name"]) })); const rawScopedTip = data["search_not_result_tips"]; const scopedTip = typeof rawScopedTip === "string" ? rawScopedTip.trim() : ""; const rawResultTip = result["search_not_result_tips"]; const resultTip = typeof rawResultTip === "string" ? rawResultTip.trim() : ""; return { ok: true, items, linkCount, timeRanges, tip: scopedTip !== "" ? scopedTip : resultTip }; } async function requestCommunitySearch(topicId, keyword, offset, timeRange, signal) { const signed = await fetchCommunitySearchPage(buildCommunitySearchUrl(topicId, keyword, offset, timeRange, true), signal); if (signed.aborted === true) return signed; if (signed.ok === true) return signed; const plain = await fetchCommunitySearchPage(buildCommunitySearchUrl(topicId, keyword, offset, timeRange, false), signal); return plain.ok === true ? plain : signed; } function pickSearchText(values) { for (const value of values) { if (typeof value === "string" && value.trim() !== "") return value.trim(); if (typeof value === "number" && Number.isFinite(value)) return String(value); } return ""; } function pickSearchNumber(values) { for (const value of values) { const parsed = readFiniteNumber(value); if (parsed !== null && parsed > 0) return parsed; } return 0; } function normalizeSearchResult(entry) { const record = asRecord(entry); if (record === null) return null; const type = record["type"]; if (typeof type === "string" && type !== "" && type !== "link") return null; const info = asRecord(record["info"]) ?? record; const id = pickSearchText([info["linkid"], info["link_id"], info["id"]]); if (id === "") return null; const user = asRecord(info["user"]) ?? {}; const thumbs = Array.isArray(info["thumbs"]) ? info["thumbs"] : []; return { id, title: pickSearchText([info["title"], info["name"]]) || "无标题帖子", desc: normalizeRailText(pickSearchText([info["description"], info["desc"], info["text"], info["content"], info["summary"]])).slice(0, 160), author: pickSearchText([user["username"], user["name"], user["nickname"]]), thumb: pickSearchText([thumbs[0], info["thumb"], info["cover"], info["image"]]), createAt: pickSearchNumber([info["create_at"], info["create_time"], info["timestamp"]]), like: pickSearchNumber([info["like_num"], info["like_count"], info["up"], info["likes"]]), comment: pickSearchNumber([info["comment_num"], info["link_comment_num"], info["comment_count"], info["comments"]]) }; } function createCommunitySearchItem(item) { const node = document.createElement("a"); node.className = "item"; node.href = LINK_HREF_PREFIX + item.id; node.addEventListener("click", (event) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigateToPath(LINK_HREF_PREFIX + item.id); }); if (item.thumb !== "") { const thumb = document.createElement("img"); thumb.className = "item-thumb"; thumb.src = item.thumb; thumb.alt = ""; thumb.loading = "lazy"; thumb.referrerPolicy = "no-referrer"; node.append(thumb); } const title = document.createElement("h3"); title.className = "item-title"; title.textContent = item.title; node.append(title); if (item.desc !== "") { const desc = document.createElement("p"); desc.className = "item-desc"; desc.textContent = item.desc; node.append(desc); } const parts = []; if (item.author !== "") parts.push(item.author); if (item.createAt > 0) parts.push(formatRailAge(item.createAt)); if (item.like > 0) parts.push("赞 " + item.like); if (item.comment > 0) parts.push("评论 " + item.comment); if (parts.length > 0) { const meta = document.createElement("p"); meta.className = "item-meta"; meta.textContent = parts.join(" · "); node.append(meta); } return node; } function createCommunitySearchPanel() { const root = document.createElement("section"); root.id = COMMUNITY_SEARCH_ID; root.setAttribute("aria-label", "社区内搜索"); const bar = document.createElement("form"); bar.className = "bar"; bar.setAttribute("role", "search"); const icon = document.createElement("span"); icon.className = "icon"; icon.innerHTML = COMMUNITY_SEARCH_ICONS.search; const scope = document.createElement("span"); scope.className = "scope"; const input = document.createElement("input"); input.className = "q"; input.type = "search"; input.name = "q"; input.autocomplete = "off"; input.placeholder = "搜索本社区的帖子"; input.setAttribute("aria-label", "搜索本社区的帖子"); const go = document.createElement("button"); go.type = "submit"; go.className = "go"; go.textContent = "搜索"; bar.append(icon, scope, input, go); const panel = document.createElement("div"); panel.className = "panel"; panel.hidden = true; const head = document.createElement("div"); head.className = "head"; const headText = document.createElement("div"); headText.className = "head-text"; const headTitle = document.createElement("p"); headTitle.className = "head-title"; const headMeta = document.createElement("p"); headMeta.className = "head-meta"; headMeta.setAttribute("role", "status"); headMeta.setAttribute("aria-live", "polite"); headText.append(headTitle, headMeta); const headActions = document.createElement("div"); headActions.className = "head-actions"; const globalLink = document.createElement("a"); globalLink.className = "ghost"; globalLink.href = "/app/search/list"; globalLink.textContent = "全站搜索"; globalLink.addEventListener("click", (event) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigateToPath(globalLink.getAttribute("href") || "/app/search/list"); }); const close = document.createElement("button"); close.type = "button"; close.className = "close"; close.textContent = "返回社区"; headActions.append(globalLink, close); head.append(headText, headActions); const filters = document.createElement("div"); filters.className = "filters"; filters.hidden = true; const list = document.createElement("div"); list.className = "list"; const foot = document.createElement("div"); foot.className = "foot"; foot.hidden = true; const more = document.createElement("button"); more.type = "button"; more.className = "more"; more.textContent = "加载更多"; const status = document.createElement("p"); status.className = "status"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); foot.append(more, status); panel.append(head, filters, list, foot); root.append(bar, panel); return { root, bar, input, scope, panel, headTitle, headMeta, globalLink, filters, list, foot, more, status, close }; } function watchCommunitySearch() { if (!document.body) return; const ui = createCommunitySearchPanel(); let state = null; let searchController = null; const pageNode = () => document.querySelector("#page-topic-link"); const mainNode = () => document.querySelector("#page-topic-link .topic-link__main"); const setActive = (active) => { const page = pageNode(); if (page !== null) page.classList.toggle(COMMUNITY_SEARCH_ACTIVE_CLASS, active === true); ui.panel.hidden = active !== true; }; const renderChips = () => { ui.filters.textContent = ""; const ranges = state === null ? [] : state.timeRanges; if (ranges.length === 0) { ui.filters.hidden = true; return; } ui.filters.hidden = false; const build = (label, value) => { const chip = document.createElement("button"); chip.type = "button"; chip.className = "chip"; chip.textContent = label; chip.setAttribute("aria-pressed", String(state !== null && state.timeRange === value)); chip.addEventListener("click", () => { const current = state; if (current === null || current.loading === true || current.timeRange === value) return; current.timeRange = value; current.offset = 0; current.items = []; current.noMore = false; void run(true); }); ui.filters.append(chip); }; build("不限时间", ""); for (const range of ranges) build(range.name, range.value); }; const renderItems = () => { ui.list.textContent = ""; const items = state === null ? [] : state.items; if (items.length > 0) { for (const item of items) ui.list.append(createCommunitySearchItem(item)); return; } const empty = document.createElement("p"); empty.className = "empty"; if (state === null) empty.textContent = ""; else if (state.loading === true) empty.textContent = "正在搜索…"; else if (state.error !== "") empty.textContent = state.error; else if (state.tip !== "") empty.textContent = state.tip; else empty.textContent = "这个社区里没有匹配的帖子,换个关键词试试。"; ui.list.append(empty); }; const render = () => { if (state === null) return; ui.headTitle.textContent = "在「" + state.topicName + "」中搜索 “" + state.keyword + "”"; const loaded = state.items.length; if (state.loading === true) { ui.headMeta.textContent = loaded === 0 ? "搜索中…" : "已加载 " + loaded + " 条,正在继续加载…"; } else if (loaded === 0) { ui.headMeta.textContent = state.error === "" ? "" : "共 0 条"; } else { ui.headMeta.textContent = "本社区已加载 " + loaded + " 条" + (state.noMore === true ? "" : "(还有更多)"); } ui.globalLink.setAttribute("href", "/app/search/list?q=" + encodeURIComponent(state.keyword)); renderChips(); renderItems(); const moreHadFocus = document.activeElement === ui.more; ui.foot.hidden = loaded > 0 || state.loading === true ? false : true; ui.more.hidden = state.noMore === true; ui.more.disabled = state.loading === true; if ((ui.more.hidden === true || ui.more.disabled === true) && moreHadFocus) ui.input.focus(); if (state.status !== "") ui.status.textContent = state.status; else if (state.noMore === true && loaded > 0) ui.status.textContent = "已到最后一页"; else ui.status.textContent = ""; }; const run = async (reset) => { const current = state; if (current === null || current.loading === true) return; current.loading = true; current.error = ""; current.status = reset === true ? "" : "加载中…"; render(); const offset = reset === true ? 0 : current.offset; searchController?.abort(); const controller = new AbortController(); searchController = controller; const response = await requestCommunitySearch(current.topicId, current.keyword, offset, current.timeRange, controller.signal); if (state !== current || searchController !== controller) return; searchController = null; current.loading = false; current.status = ""; if (response.aborted === true) { current.status = "搜索已取消"; render(); return; } if (response.ok !== true) { if (reset === true) { current.items = []; current.error = "搜索失败:" + response.message; } else { current.status = "加载失败:" + response.message; } render(); return; } const known = new Set(current.items.map((item) => item.id)); const incoming = []; for (const entry of response.items) { const item = normalizeSearchResult(entry); if (item === null || known.has(item.id)) continue; known.add(item.id); incoming.push(item); } current.items = reset === true ? incoming : current.items.concat(incoming); current.offset = offset + response.linkCount; current.noMore = response.linkCount === 0; current.tip = response.tip; if (current.timeRanges.length === 0 && response.timeRanges.length > 0) current.timeRanges = response.timeRanges; render(); }; const clearPanel = () => { ui.list.textContent = ""; ui.filters.textContent = ""; ui.filters.hidden = true; ui.foot.hidden = true; ui.headMeta.textContent = ""; }; const close = (restoreFocus) => { searchController?.abort(); searchController = null; state = null; clearPanel(); setActive(false); if (restoreFocus === true) ui.input.focus(); }; const submit = () => { const context = readTopicContext(); if (context === null) return; const keyword = ui.input.value.trim(); if (keyword === "") { ui.input.focus(); return; } searchController?.abort(); searchController = null; state = { topicId: context.topicId, topicName: context.name, keyword, timeRange: "", items: [], offset: 0, noMore: false, timeRanges: [], tip: "", error: "", status: "", loading: false }; setActive(true); render(); void run(true); }; ui.bar.addEventListener("submit", (event) => { event.preventDefault(); submit(); }); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || state === null || event.defaultPrevented === true) return; const target = event.target; if (target instanceof Element) { if (target !== ui.input && target.closest("input, textarea, select, [contenteditable]") !== null) return; const onPage = target === document.body || target === document.documentElement; if (onPage !== true && ui.root.contains(target) !== true) return; if (onPage === true) { const overlayOpen = Array.from(document.querySelectorAll("[aria-modal='true'], .el-overlay-dialog")).some((node) => node.getClientRects().length > 0); if (overlayOpen === true) return; } } event.preventDefault(); close(true); }); ui.close.addEventListener("click", () => close(true)); ui.more.addEventListener("click", () => { void run(false); }); const sync = () => { ensureCommunitySearchStyle(); const context = readTopicContext(); const main = mainNode(); if (context === null || main === null) { searchController?.abort(); searchController = null; if (ui.root.parentNode !== null) ui.root.remove(); if (state !== null) { state = null; clearPanel(); } setActive(false); return; } const anchor = main.querySelector(".topic-link__filter-row"); const mount = anchor === null ? main : anchor.parentNode; if (mount === null) return; const misplaced = ui.root.parentNode !== mount || anchor !== null && ui.root.nextElementSibling !== anchor; if (misplaced === true) { if (anchor !== null) mount.insertBefore(ui.root, anchor); else mount.append(ui.root); } if (ui.scope.textContent !== context.name) ui.scope.textContent = context.name; const placeholder = "在「" + context.name + "」中搜索帖子"; if (ui.input.placeholder !== placeholder) ui.input.placeholder = placeholder; if (ui.input.getAttribute("aria-label") !== placeholder) ui.input.setAttribute("aria-label", placeholder); if (state !== null && state.topicId !== context.topicId) { searchController?.abort(); searchController = null; state = null; clearPanel(); } setActive(state !== null); }; registerPageSync(sync, 250, [PAGE_ROUTE.TOPIC], "topic"); window.addEventListener("popstate", () => { if (state !== null) close(false); }); sync(); } // scripts/heybox-up/src/ui/desktop.ts var DESKTOP_STYLE_ID = "heybox-up-desktop-style"; var DESKTOP_LEFT_ID = "heybox-up-left-rail"; var DESKTOP_RIGHT_ID = "heybox-up-right-rail"; var DESKTOP_PAGE_CLASS = "heybox-up-desktop"; var DESKTOP_HOT_TARGET_CLASS = "heybox-up-hot-target"; var DESKTOP_HOT_LIMIT = 6; var DESKTOP_CSS = ` #heybox-up-left-rail, #heybox-up-right-rail { display: none; } #heybox-up-left-rail, #heybox-up-right-rail { position: sticky; /* 桌面隐藏站点顶部搜索条后,侧栏直接停在导航下方。 */ top: 80px; max-height: calc(100vh - 100px); overflow: auto; font: 13px/1.55 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; color: var(--heybox-up-text-2, #3c4249); scrollbar-width: thin; } #heybox-up-left-rail .rail-block, #heybox-up-right-rail .rail-block { padding: 13px 10px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .1)); border-radius: var(--heybox-up-radius-card, 14px); background: var(--heybox-up-surface, #fff); box-shadow: var(--heybox-up-shadow-1, none); } #heybox-up-left-rail .rail-block + .rail-block, #heybox-up-right-rail .rail-block + .rail-block { margin-top: 12px; } #heybox-up-left-rail .rail-title, #heybox-up-right-rail .rail-title { margin: 0 0 7px; padding: 0 8px; color: var(--heybox-up-muted, #6b7280); font-size: 11px; font-weight: 600; line-height: 1.5; letter-spacing: .08em; } #heybox-up-left-rail .rail-link, #heybox-up-left-rail .rail-channel, #heybox-up-right-rail .rail-link { display: flex; align-items: center; gap: 9px; width: 100%; height: 34px; margin: 0; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--heybox-up-text-2, #3c4249); font: inherit; font-size: 13px; line-height: 34px; text-align: left; text-decoration: none; white-space: nowrap; cursor: pointer; transition: background .14s ease, color .14s ease; } #heybox-up-left-rail .rail-link:hover, #heybox-up-left-rail .rail-channel:hover, #heybox-up-right-rail .rail-link:hover { background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); color: var(--heybox-up-text, #14191e); } #heybox-up-left-rail .rail-link.is-active { background: rgba(111, 141, 179, .13); color: var(--heybox-up-accent, #6f8db3); font-weight: 600; } #heybox-up-left-rail .rail-link svg, #heybox-up-right-rail .rail-link svg { width: 17px; height: 17px; flex: none; } #heybox-up-left-rail .rail-channel img { width: 22px; height: 22px; flex: none; border-radius: 6px; object-fit: cover; background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); } #heybox-up-left-rail .rail-channel .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } #heybox-up-left-rail .channels { max-height: 288px; overflow: auto; scrollbar-width: thin; } #heybox-up-left-rail .rail-link:focus-visible, #heybox-up-left-rail .rail-channel:focus-visible, #heybox-up-right-rail .rail-link:focus-visible, #heybox-up-right-rail .hot-item:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: -2px; } #heybox-up-right-rail .hot-list { display: flex; flex-direction: column; gap: 1px; margin: 0; padding: 0; list-style: none; } #heybox-up-right-rail .hot-item { display: grid; grid-template-columns: 18px minmax(0, 1fr); column-gap: 7px; width: 100%; margin: 0; padding: 8px 6px; border: 0; border-radius: 8px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: background .14s ease; } #heybox-up-right-rail .hot-item:hover { background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); } #heybox-up-right-rail .hot-rank { color: var(--heybox-up-muted, #6b7280); font-size: 12px; font-weight: 700; line-height: 1.5; font-variant-numeric: tabular-nums; } #heybox-up-right-rail .hot-item:nth-child(-n+3) .hot-rank { color: var(--heybox-up-accent, #6f8db3); } #heybox-up-right-rail .hot-title { display: -webkit-box; overflow: hidden; color: var(--heybox-up-text-2, #3c4249); font-size: 13px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } #heybox-up-right-rail .hot-item:hover .hot-title { color: var(--heybox-up-text, #14191e); } #heybox-up-right-rail .hot-meta { grid-column: 2; margin-top: 2px; color: var(--heybox-up-muted, #6b7280); font-size: 11px; line-height: 1.5; } #heybox-up-right-rail .rail-empty { margin: 0; padding: 10px 8px; color: var(--heybox-up-muted, #6b7280); font-size: 12px; line-height: 1.6; } #page-bbs-community .heybox-up-hot-target { border-color: var(--heybox-up-accent, #6f8db3) !important; box-shadow: 0 0 0 3px rgba(111, 141, 179, .18) !important; } @media (min-width: 1200px) { /* 顶部搜索条会横跨信息流、压住左栏上沿;桌面把搜索入口收进左栏“浏览”。 站点还用固定 ::before 给搜索条铺 146px 高的遮罩;搜索条隐藏后把遮罩压到 导航高度,既保留导航下方 16px 的顶部留白,也避免滚动时正文从留白处露出。 */ #page-bbs-community.heybox-up-desktop .bbs-community__search-module { display: none !important; } #page-bbs-community.heybox-up-desktop::before { bottom: auto !important; height: 80px !important; } /* 站点还让 main.list 的 8px sticky 伪元素停在原搜索条下沿;搜索条隐藏后 它会在 138px 处横切信息流并截获点击。保留其占位,取消吸顶与绘制。 */ #page-bbs-community.heybox-up-desktop main.list::before { position: static !important; background: transparent !important; pointer-events: none !important; } #page-bbs-community.heybox-up-desktop .content { display: grid !important; width: 100% !important; /* 左右两列等宽,中间栏永远落在视口正中;两侧栏贴近中间栏而不是贴屏幕边。 */ grid-template-columns: minmax(0, 1fr) /* 中栏再宽也要给两侧栏留出 208px + 288px + 间距,否则侧栏会压到正文上。 */ minmax(320px, min(var(--heybox-up-feed-w), calc(100vw - 560px))) minmax(0, 1fr); column-gap: 24px; align-items: start; max-width: none !important; margin: 0 auto !important; justify-content: stretch !important; } #page-bbs-community.heybox-up-desktop .content > #heybox-up-left-rail { display: block; grid-column: 1; grid-row: 1; justify-self: end; width: 208px; align-self: start; } #page-bbs-community.heybox-up-desktop .content > main.list { grid-column: 2; grid-row: 1; width: auto !important; max-width: none !important; min-width: 0; margin: 0 !important; flex: none !important; } /* 评论预览只占右侧一列的真实宽度。旧实现再叠一层“侧栏 + 48px”右内边距, 会在面板与信息流之间留下近乎双倍于面板的空白,并把中栏挤向左侧。 */ body.heybox-up-rail-open #app > main { max-width: none !important; } body.heybox-up-rail-open #page-bbs-community.heybox-up-desktop .content { grid-template-columns: minmax(var(--heybox-up-rail-w), 1fr) minmax(320px, min(var(--heybox-up-feed-w), calc(100% - var(--heybox-up-rail-w) - var(--heybox-up-rail-w) - 48px))) minmax(var(--heybox-up-rail-w), 1fr); column-gap: 24px; } /* 左栏已有频道列表,原生横向频道条不再重复出现。 */ #page-bbs-community.heybox-up-desktop .bbs-home__topic-list-wrapper { display: none !important; } } @media (min-width: 1500px) { body.heybox-up-desktop-on #app > main { max-width: none !important; } body.heybox-up-desktop-on #page-bbs-community { max-width: none !important; width: auto !important; } #page-bbs-community.heybox-up-desktop .content > #heybox-up-right-rail { display: block; grid-column: 3; grid-row: 1; justify-self: start; width: 288px; align-self: start; } } @media (min-width: 1500px) { body.heybox-up-rail-open #page-bbs-community.heybox-up-desktop #heybox-up-right-rail { display: none; } } `; function ensureDesktopStyle() { ensureStyleElement(DESKTOP_STYLE_ID, DESKTOP_CSS); } function parseCompactCount(value) { const match = /([0-9]+(?:\.[0-9]+)?)\s*(亿|万)?/.exec(value || ""); if (match === null) return 0; const base = Number(match[1]); if (!Number.isFinite(base)) return 0; const unit = match[2] === "亿" ? 1e8 : match[2] === "万" ? 1e4 : 1; return Math.round(base * unit); } function createDesktopRailLink(item, active) { const link = document.createElement("a"); link.className = "rail-link" + (active === true ? " is-active" : ""); link.href = item.href; link.dataset["route"] = item.id; link.innerHTML = item.icon; const label = document.createElement("span"); label.textContent = item.label; link.append(label); link.addEventListener("click", (event) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigateToPath(item.href); }); return link; } function createDesktopLeftRail() { const root = document.createElement("aside"); root.id = DESKTOP_LEFT_ID; root.setAttribute("aria-label", "浏览与频道"); const browse = document.createElement("section"); browse.className = "rail-block"; const browseTitle = document.createElement("p"); browseTitle.className = "rail-title"; browseTitle.textContent = "浏览"; browse.append(browseTitle); const links = [ { id: "community", label: "社区首页", href: "/app/bbs/home", icon: navIconMarkupFor("community") }, { id: "favour", label: "我的收藏", href: "/app/user/favour/content", icon: navIconMarkupFor("favour") }, { id: "article", label: "我的文章", href: "/creator/content_management/home?article_type=all", icon: navIconMarkupFor("article") }, { id: "search", label: "全站搜索", href: "/app/search/list", icon: COMMUNITY_SEARCH_ICONS.search } ]; for (const item of links) browse.append(createDesktopRailLink(item, item.id === "community")); const channelBlock = document.createElement("section"); channelBlock.className = "rail-block"; const channelTitle = document.createElement("p"); channelTitle.className = "rail-title"; channelTitle.textContent = "频道"; const channels = document.createElement("div"); channels.className = "channels"; channelBlock.append(channelTitle, channels); root.append(browse, channelBlock); return { root, channels }; } function createDesktopRightRail() { const root = document.createElement("aside"); root.id = DESKTOP_RIGHT_ID; root.setAttribute("aria-label", "热门与快捷入口"); const hotBlock = document.createElement("section"); hotBlock.className = "rail-block"; const hotTitle = document.createElement("p"); hotTitle.className = "rail-title"; hotTitle.textContent = "热门排行"; const hot = document.createElement("ol"); hot.className = "hot-list"; hotBlock.append(hotTitle, hot); const toolsBlock = document.createElement("section"); toolsBlock.className = "rail-block"; const toolsTitle = document.createElement("p"); toolsTitle.className = "rail-title"; toolsTitle.textContent = "快捷入口"; toolsBlock.append(toolsTitle); const tools = [ { id: "favour", label: "我的收藏", href: "/app/user/favour/content", icon: navIconMarkupFor("favour") }, { id: "article", label: "我的文章", href: "/creator/content_management/home?article_type=all", icon: navIconMarkupFor("article") } ]; for (const item of tools) toolsBlock.append(createDesktopRailLink(item, false)); root.append(hotBlock, toolsBlock); return { root, hot }; } function readDesktopChannels() { return Array.from(document.querySelectorAll("#page-bbs-community .bbs-home__topic-item")).map((button) => { const nameNode = button.querySelector(".bbs-home__topic-name"); const imageNode = button.querySelector("img.bbs-home__topic-item-icon"); const name = normalizeRailText(nameNode instanceof Element ? nameNode.textContent : button.textContent).slice(0, 24); return { name, icon: imageNode === null ? "" : imageNode.getAttribute("src") || "" }; }).filter((entry) => entry.name !== ""); } function syncDesktopChannels(ui, cache) { const channels = readDesktopChannels(); const signature = channels.map((entry) => entry.name + "" + entry.icon).join(""); if (signature === cache.channels) return; cache.channels = signature; ui.channels.textContent = ""; if (channels.length === 0) { const empty = document.createElement("p"); empty.className = "rail-empty"; empty.textContent = "频道列表还没渲染好"; ui.channels.append(empty); return; } for (const channel of channels) { const button = document.createElement("button"); button.type = "button"; button.className = "rail-channel"; if (channel.icon !== "") { const icon = document.createElement("img"); icon.src = channel.icon; icon.alt = ""; icon.loading = "lazy"; icon.referrerPolicy = "no-referrer"; button.append(icon); } const name = document.createElement("span"); name.className = "name"; name.textContent = channel.name; button.append(name); button.addEventListener("click", () => { const native = Array.from(document.querySelectorAll("#page-bbs-community .bbs-home__topic-item")).find((node) => { const label = node.querySelector(".bbs-home__topic-name"); return normalizeRailText(label === null ? node.textContent : label.textContent) === channel.name; }); if (!native) return; for (const node of ui.channels.querySelectorAll(".rail-channel")) node.classList.remove("is-active"); button.classList.add("is-active"); if (native instanceof HTMLElement) native.click(); }); ui.channels.append(button); } } function readDesktopHotItems() { return Array.from(document.querySelectorAll("#page-bbs-community .bbs-home__content-item")).map((card, index) => { const link = card.querySelector("a[href*='/app/bbs/link/']"); const href = link === null ? "" : link.getAttribute("href") || ""; const idMatch = /\/app\/bbs\/link\/(\d+)/.exec(href); const titleNode = card.querySelector(".bbs-content__title"); const title = normalizeRailText(titleNode instanceof Element ? titleNode.textContent : card.textContent).slice(0, 80); const commentNode = card.querySelector(".bbs-new-style-bottom__comment, .content-list__comment"); const likeNode = card.querySelector(".bbs-new-style-bottom__like, .content-list__like"); const comment = parseCompactCount(commentNode === null ? "" : commentNode.textContent); const like = parseCompactCount(likeNode === null ? "" : likeNode.textContent); return { card, index, id: idMatch === null ? "i" + index : idMatch[1], title, comment, like, score: comment * 2 + like }; }).filter((entry) => entry.title !== "").sort((left, right) => right.score - left.score).slice(0, DESKTOP_HOT_LIMIT); } function syncDesktopHot(ui, cache) { const items = readDesktopHotItems(); const signature = items.map((item) => item.id + ":" + item.score + ":" + item.title).join("|"); if (signature === cache.hot) return; cache.hot = signature; ui.hot.textContent = ""; if (items.length === 0) { const empty = document.createElement("p"); empty.className = "rail-empty"; empty.textContent = "信息流还没有可排序的热度数据"; ui.hot.append(empty); return; } items.forEach((item, index) => { const row = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.className = "hot-item"; const rank = document.createElement("span"); rank.className = "hot-rank"; rank.textContent = String(index + 1); const title = document.createElement("span"); title.className = "hot-title"; title.textContent = item.title; const meta = document.createElement("span"); meta.className = "hot-meta"; const parts = []; if (item.comment > 0) parts.push(item.comment + " 评论"); if (item.like > 0) parts.push(item.like + " 赞"); meta.textContent = parts.length === 0 ? "当前信息流" : parts.join(" · "); button.append(rank, title, meta); button.addEventListener("click", () => { item.card.scrollIntoView({ behavior: "smooth", block: "center" }); item.card.classList.add(DESKTOP_HOT_TARGET_CLASS); window.setTimeout(() => item.card.classList.remove(DESKTOP_HOT_TARGET_CLASS), 1400); }); row.append(button); ui.hot.append(row); }); } function watchDesktopShell() { if (!document.body) return; const desktopViewport = window.matchMedia("(min-width: 1200px)"); const left = createDesktopLeftRail(); const right = createDesktopRightRail(); const cache = { channels: "", hot: "" }; let lastDesktopSyncAt = 0; let lastDesktopPath = location.pathname; const teardown = () => { document.body.classList.remove("heybox-up-desktop-on"); const page = document.querySelector("#page-bbs-community"); if (page !== null) page.classList.remove(DESKTOP_PAGE_CLASS); if (left.root.parentNode !== null) left.root.remove(); if (right.root.parentNode !== null) right.root.remove(); }; const sync = (force = false) => { const now = performance.now(); if (location.pathname !== lastDesktopPath) { lastDesktopPath = location.pathname; force = true; } if (!force && now - lastDesktopSyncAt < 500) return; lastDesktopSyncAt = now; if (!desktopViewport.matches) { teardown(); return; } const page = document.querySelector("#page-bbs-community"); const content = page === null ? null : page.querySelector(".content"); if (page === null || content === null || page.querySelector("main.list") === null) { teardown(); return; } ensureDesktopStyle(); page.classList.add(DESKTOP_PAGE_CLASS); document.body.classList.add("heybox-up-desktop-on"); if (left.root.parentNode !== content) content.prepend(left.root); if (right.root.parentNode !== content) content.append(right.root); syncDesktopChannels(left, cache); syncDesktopHot(right, cache); }; registerPageSync(sync, 400, [PAGE_ROUTE.FEED], "feed"); window.addEventListener("popstate", () => requestAnimationFrame(() => sync(true))); desktopViewport.addEventListener("change", () => sync(true)); sync(true); } // scripts/heybox-up/src/ui/favour.ts var FAVOUR_HOST_ID = "heybox-up-favour"; var LOGIN_RETRY_DELAY_MS = 1500; var LOGIN_RETRY_MAX = 3; var FAVOUR_LIST_SELECTOR = ".user-favour__list"; var FAVOUR_CSS = ` :host { display: block; scroll-margin-top: 80px; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .section { padding: 14px 8px 4px; } .head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } .heading { font-size: 14px; font-weight: 700; } .refresh, .retry { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 8px; border: 1px solid rgba(127,127,127,.32); background: transparent; color: inherit; cursor: pointer; } .refresh:hover:not(:disabled), .retry:hover { border-color: rgba(127,127,127,.55); } .refresh:disabled { opacity: .5; cursor: default; } .retry { margin: -2px 0 10px; } .note { font-size: 12px; opacity: .62; margin-bottom: 10px; } .search { width: 100%; box-sizing: border-box; margin-bottom: 10px; padding: 7px 10px; border-radius: 8px; border: 1px solid rgba(127,127,127,.28); background: rgba(127,127,127,.08); color: inherit; font: inherit; font-size: 13px; } .search:focus { outline: none; border-color: #6f8db3; } .card { display: flex; gap: 10px; padding: 10px; margin-bottom: 8px; border-radius: 12px; text-decoration: none; color: inherit; border: 1px solid rgba(127,127,127,.22); } .card:hover { border-color: rgba(127,127,127,.45); } .thumb { width: 52px; height: 52px; border-radius: 9px; object-fit: cover; flex: none; background: rgba(127,127,127,.16); } .body { min-width: 0; flex: 1; } .card-title { font-weight: 600; font-size: 13px; margin-bottom: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .card-text { display: flex; gap: 6px; align-items: flex-start; margin-bottom: 6px; font-size: 14px; line-height: 1.5; color: inherit; } .card-text-icon { width: 15px; height: 15px; flex: none; margin-top: 2px; color: #6f8db3; } .card-text-body { min-width: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .card-text.is-empty { font-size: 12px; opacity: .55; } .card-foot { margin-top: 5px; font-size: 11px; opacity: .6; display: flex; gap: 10px; } `; function createFavourSection() { const host = document.createElement("div"); host.id = FAVOUR_HOST_ID; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = FAVOUR_CSS; shadow.append(style); const section = document.createElement("section"); section.className = "section"; const head = document.createElement("div"); head.className = "head"; const heading = document.createElement("div"); heading.className = "heading"; heading.textContent = "插眼收藏"; const refresh = document.createElement("button"); refresh.type = "button"; refresh.className = "refresh"; refresh.textContent = "刷新"; refresh.setAttribute("aria-label", "刷新插眼收藏"); head.append(heading, refresh); const note = document.createElement("div"); note.className = "note"; note.textContent = "正在读取插眼收藏…"; const retry = document.createElement("button"); retry.type = "button"; retry.className = "retry"; retry.textContent = "重试"; retry.hidden = true; const search = document.createElement("input"); search.type = "search"; search.className = "search"; search.placeholder = "搜索标题或插眼内容"; search.setAttribute("aria-label", "搜索插眼收藏"); search.hidden = true; const list = document.createElement("div"); list.className = "list"; section.append(head, note, retry, search, list); shadow.append(section); const state = { items: [], loading: false, error: "", notice: "", progress: 0, query: "" }; let loadController = null; let loginRetryTimer = 0; let loginRetryLeft = 0; let disposed = false; const render = () => { const query = state.query.trim(); const needle = query.toLowerCase(); const matched = needle === "" ? state.items : state.items.filter((item) => item.title.toLowerCase().includes(needle) || item.text.toLowerCase().includes(needle)); list.replaceChildren(); refresh.disabled = state.loading; refresh.textContent = state.loading ? "读取中…" : "刷新"; retry.hidden = state.error === ""; search.hidden = state.items.length === 0; if (state.error !== "") { note.textContent = "读取失败:" + state.error + (state.items.length > 0 ? "(显示上次结果)" : ""); } else if (state.notice !== "") { note.textContent = state.notice; } else if (state.loading) { note.textContent = "正在读取插眼收藏…" + (state.progress > 0 ? " 已获取 " + state.progress + " 条" : ""); } else if (state.items.length === 0) { note.textContent = "没有插眼收藏。"; } else if (matched.length === 0) { note.textContent = "没有匹配“" + query + "”的插眼条目。"; } else { note.textContent = "共 " + state.items.length + " 条插眼收藏" + (query !== "" ? ",匹配 " + matched.length + " 条" : "") + ",网页收藏列表默认不显示。"; } if (matched.length === 0) return; const fragment = document.createDocumentFragment(); for (const item of matched) fragment.append(createCard(item)); list.append(fragment); }; const readFavourContainer = () => { const page = document.querySelector("#page-user-favour"); if (!page) return null; return page.querySelector(".content") ?? page; }; const place = () => { const listNode = document.querySelector(FAVOUR_LIST_SELECTOR); if (listNode && listNode.parentElement) { const container2 = listNode.parentElement; if (host.parentNode !== container2 || host.previousElementSibling !== listNode) { container2.insertBefore(host, listNode.nextSibling); } return; } const container = readFavourContainer(); if (container === null) return; if (host.parentNode !== container || host.nextSibling !== null) container.append(host); }; const load = async (force) => { if (disposed || state.loading) return; window.clearTimeout(loginRetryTimer); loginRetryTimer = 0; state.loading = true; state.error = ""; state.notice = ""; state.progress = 0; render(); loadController?.abort(); loadController = new AbortController(); let loginMissing = false; try { const userId = await resolveHeyboxId(loadController.signal); if (userId === "") { loginMissing = true; state.notice = "未登录小黑盒,无法读取插眼收藏。"; return; } loginRetryLeft = 0; const cached = force ? null : readCache(userId); if (cached) { state.items = cached; return; } state.items = await loadCyFavorites(userId, (count) => { state.progress = count; note.textContent = "正在读取插眼收藏… 已获取 " + count + " 条"; }, loadController.signal); writeCache(userId, state.items); } catch (error) { if (isAbortError(error)) return; state.error = error instanceof Error ? error.message : String(error); } finally { state.loading = false; loadController = null; render(); if (loginMissing && loginRetryLeft < LOGIN_RETRY_MAX) { loginRetryLeft += 1; loginRetryTimer = window.setTimeout(() => void load(false), LOGIN_RETRY_DELAY_MS); } } }; refresh.addEventListener("click", () => void load(true)); retry.addEventListener("click", () => void load(true)); search.addEventListener("input", () => { state.query = search.value; render(); }); place(); void load(false); return { host, place, dispose: () => { disposed = true; window.clearTimeout(loginRetryTimer); loginRetryTimer = 0; loginRetryLeft = LOGIN_RETRY_MAX; loadController?.abort(); loadController = null; state.loading = false; } }; } function watchFavourPage() { if (!document.body) return; let section = null; let cyJumpDone = false; const sync = () => { if (readPageRoute() !== PAGE_ROUTE.FAVOUR) { cyJumpDone = false; if (section) { section.dispose(); section.host.remove(); section = null; } return; } if (!section) section = createFavourSection(); section.place(); if (!cyJumpDone && location.hash === CY_HASH) { requestAnimationFrame(() => { if (!scrollToCySection()) return; cyJumpDone = true; }); } }; registerPageSync(sync, 300, [PAGE_ROUTE.FAVOUR], "favour"); sync(); } function readFavourId(value) { if (value === void 0 || value === null) return ""; const text = String(value).trim(); if (text === "" || text === "0" || text === "-1" || text === "undefined" || text === "null") return ""; return text; } function normalizeFavourFolders(records) { const folders = []; const seen = /* @__PURE__ */ new Set(); for (const entry of records) { const record = asRecord(entry); if (record === null) continue; const id = readFavourId(record["id"] ?? record["folder_id"]); if (id === "" || seen.has(id)) continue; seen.add(id); const rawName = String(record["name"] ?? record["folder_name"] ?? "").trim(); folders.push({ id, name: rawName === "" ? "未命名收藏夹" : rawName }); } return folders; } function readFavourFolderId(result) { const record = asRecord(result); if (record === null) return ""; const folder = asRecord(record["folder"]); return readFavourId(folder?.["id"] ?? folder?.["folder_id"] ?? record["folder_id"]); } function readFavourLinkId(entry) { const record = asRecord(entry); if (record === null) return ""; const link = asRecord(record["link"]); return readFavourId( link?.["linkid"] ?? link?.["link_id"] ?? link?.["id"] ?? record["link_id"] ?? record["linkid"] ); } function readFavourFolderPage(result) { const record = asRecord(result); if (record === null) return { linkIds: [], hasNext: false }; const rawLinks = Array.isArray(record["links"]) ? record["links"] : []; const linkIds = []; for (const entry of rawLinks) { const id = readFavourLinkId(entry); if (id !== "") linkIds.push(id); } const next = record["has_next"] ?? record["has_more"]; return { linkIds, hasNext: next === true || next === 1 || next === "1" }; } function planFavourChanges(input) { const folderIds = new Set(input.folderIds); const checked = new Set(input.checkedIds.filter((id) => folderIds.has(id))); const unknown = input.unknownIds.filter((id) => folderIds.has(id)); const unknownSet = new Set(unknown); const members = new Set(input.memberIds.filter((id) => folderIds.has(id) && !unknownSet.has(id))); const addIds = Array.from(checked).filter((id) => !members.has(id)); const removeIds = Array.from(members).filter((id) => !checked.has(id)); return { addIds, removeIds, // An empty folder list means the request failed or the account has no // folders yet; neither is proof that clearing every checkbox should // unfavourite the link globally. removeAll: folderIds.size > 0 && input.favourited && checked.size === 0 && unknown.length === 0, unknownIds: unknown }; } function planHasFavourChange(plan) { return plan.removeAll || plan.addIds.length > 0 || plan.removeIds.length > 0; } function resolveFavourOutcome(favourited, memberIds, plan) { if (plan.removeAll) return false; const removed = new Set(plan.removeIds); const remaining = /* @__PURE__ */ new Set(); for (const id of memberIds) { if (!removed.has(id)) remaining.add(id); } for (const id of plan.addIds) remaining.add(id); if (remaining.size > 0) return true; return favourited && plan.unknownIds.length > 0; } // scripts/heybox-up/src/core/feed.ts var PAGER_ID = "heybox-up-post-pager"; var FEED_ORDER_KEY = "heybox-up.feedOrder.v1"; var POST_TRAIL_KEY = "heybox-up.postTrail.v1"; var POST_TRAIL_MAX = 40; var FEED_ORDER_MAX = 600; var FEED_QUEUE_KEY = "heybox-up.feedQueue.v1"; var FEED_CURSOR_KEY = "heybox-up.feedCursor.v1"; var FEED_SEEN_KEY = "heybox-up.feedSeen.v1"; var FEED_RECENT_KEY = "heybox-up.feedRecent.v1"; var FEED_DISCOVERY_API_PATH = "/bbs/app/feeds"; var FEED_DISCOVERY_PAGE_SIZE = 30; var FEED_DISCOVERY_PASS_PAGES = 3; var FEED_QUEUE_MAX = 80; var FEED_SEEN_MAX = 600; var FEED_RECENT_MAX = 12; var FEED_TITLE_MAX = 80; var FEED_DISCOVERY_BODY_TIMEOUT_MS = 15e3; function buildFeedStateKey(baseKey, accountId) { const account = normalizeFeedText(accountId, 80); return baseKey + ":" + (account === "" ? "anonymous" : account); } function computeFeedDiscoveryOffset(feedOrder, pageSize) { const loaded = Array.isArray(feedOrder) ? feedOrder.length : 0; const size = Number.isSafeInteger(pageSize) && Number(pageSize) > 0 ? Number(pageSize) : 30; if (loaded <= 0) return 0; return Math.ceil(loaded / size) * size; } function normalizeFeedId(value) { if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) return String(value); if (typeof value !== "string") return ""; const trimmed = value.trim(); return /^[1-9]\d*$/.test(trimmed) ? trimmed : ""; } function normalizeFeedText(value, maxLength = FEED_TITLE_MAX) { if (typeof value !== "string" && typeof value !== "number") return ""; return String(value).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength); } function readFeedContentType(value) { if (typeof value === "number") return Number.isFinite(value) ? value : NaN; if (typeof value !== "string") return NaN; const trimmed = value.trim(); return /^\d+$/.test(trimmed) ? Number(trimmed) : NaN; } function isFeedFallbackTitle(title, id) { return title === "" || title === "帖子 " + id; } function normalizeFeedCandidate(raw) { const record = asRecord(raw); if (record === null) return null; if (readFeedContentType(record["content_type"]) === 18) return null; const id = normalizeFeedId(record["linkid"] ?? record["link_id"] ?? record["id"]); if (id === "") return null; const user = asRecord(record["user"]); const authorId = normalizeFeedText( record["userid"] ?? record["user_id"] ?? user?.["userid"] ?? user?.["id"] ?? record["authorId"], 80 ); const author = normalizeFeedText( record["username"] ?? user?.["username"] ?? user?.["nickname"] ?? record["author"], 80 ); const topicIds = []; const topics = []; const rawTopics = record["topics"]; for (const topic of Array.isArray(rawTopics) ? rawTopics : []) { if (typeof topic === "string" || typeof topic === "number") { const topicId2 = normalizeFeedId(topic); if (topicId2 !== "") { if (!topicIds.includes(topicId2)) topicIds.push(topicId2); continue; } const topicName2 = normalizeFeedText(topic, 80); if (topicName2 !== "" && !topics.includes(topicName2)) topics.push(topicName2); continue; } const topicObject = asRecord(topic); const topicId = normalizeFeedId( topicObject?.["topic_id"] ?? topicObject?.["topicId"] ?? topicObject?.["id"] ?? topic ); const topicName = normalizeFeedText( topicObject?.["name"] ?? topicObject?.["topic_name"] ?? topicObject?.["topicName"], 80 ); if (topicId !== "" && !topicIds.includes(topicId)) topicIds.push(topicId); if (topicName !== "" && !topics.includes(topicName)) topics.push(topicName); } const rawTopicIds = record["topicIds"]; for (const topicId of Array.isArray(rawTopicIds) ? rawTopicIds : []) { const normalized = normalizeFeedId(topicId); if (normalized !== "" && !topicIds.includes(normalized)) topicIds.push(normalized); } const rawTopicsText = record["topicsText"]; for (const topicName of Array.isArray(rawTopicsText) ? rawTopicsText : []) { const normalized = normalizeFeedText(topicName, 80); if (normalized !== "" && !topics.includes(normalized)) topics.push(normalized); } const title = normalizeFeedText(record["title"]) || normalizeFeedText(record["description"]) || "帖子 " + id; return { id, title, authorId, author, topicIds, topics }; } function mergeFeedCandidateMetadata(existing, incoming) { if (existing === null) return incoming; if (incoming === null) return existing; const existingTitleFallback = isFeedFallbackTitle(existing.title, existing.id); const incomingTitleFallback = isFeedFallbackTitle(incoming.title, incoming.id); const title = existingTitleFallback && !incomingTitleFallback ? incoming.title : existing.title || incoming.title; const topicIds = [...existing.topicIds]; const topics = [...existing.topics]; for (const topicId of incoming.topicIds) { if (!topicIds.includes(topicId)) topicIds.push(topicId); } for (const topic of incoming.topics) { if (!topics.includes(topic)) topics.push(topic); } return { id: existing.id, title, authorId: existing.authorId || incoming.authorId, author: existing.author || incoming.author, topicIds, topics }; } function mergeFeedOrder(existing, incoming) { const order = []; const positions = /* @__PURE__ */ new Map(); const rows = [...Array.isArray(existing) ? existing : [], ...Array.isArray(incoming) ? incoming : []]; for (const raw of rows) { const candidate = normalizeFeedLegacyCandidate(raw); if (candidate === null) continue; const position = positions.get(candidate.id); if (position === void 0) { positions.set(candidate.id, order.length); order.push(candidate); } else { const merged = mergeFeedCandidateMetadata(order[position] ?? null, candidate); if (merged !== null) order[position] = merged; } } return order; } function normalizeFeedLegacyCandidate(raw) { if (typeof raw === "string" || typeof raw === "number" && Number.isSafeInteger(raw)) { const id = normalizeFeedId(raw); return id === "" ? null : { id, title: "帖子 " + id, authorId: "", author: "", topicIds: [], topics: [] }; } return normalizeFeedCandidate(raw); } function mergeRecentMetadata(existing, current, max = FEED_RECENT_MAX) { const merged = []; const positions = /* @__PURE__ */ new Map(); const rows = [current, ...Array.isArray(existing) ? existing : []]; for (const raw of rows) { const candidate = normalizeFeedCandidate(raw); if (candidate === null) continue; const position = positions.get(candidate.id); if (position === void 0) { positions.set(candidate.id, merged.length); merged.push(candidate); } else { const next = mergeFeedCandidateMetadata(merged[position] ?? null, candidate); if (next !== null) merged[position] = next; } } return merged.slice(0, Math.max(0, max)); } function mergeFeedSeen(existing, ids, max = FEED_SEEN_MAX) { const seen = []; const known = /* @__PURE__ */ new Set(); const rows = [...Array.isArray(existing) ? existing : [], ...Array.isArray(ids) ? ids : []]; for (const raw of rows) { const id = normalizeFeedId(raw); if (id === "" || known.has(id)) continue; known.add(id); seen.push(id); } const limit = Math.max(0, max); return seen.slice(Math.max(0, seen.length - limit)); } function mergeFeedQueue(queue, incoming, options = {}) { const currentId = normalizeFeedId(options.currentId); const visited = options.visited instanceof Set ? options.visited : /* @__PURE__ */ new Set(); const seen = options.seen instanceof Set ? options.seen : /* @__PURE__ */ new Set(); const max = typeof options.max === "number" && Number.isInteger(options.max) ? Math.max(0, options.max) : FEED_QUEUE_MAX; const merged = []; const positions = /* @__PURE__ */ new Map(); const add = (raw) => { const candidate = normalizeFeedLegacyCandidate(raw); if (candidate === null) return; if (candidate.id === currentId || visited.has(candidate.id) || seen.has(candidate.id)) return; const existingPosition = positions.get(candidate.id); if (existingPosition !== void 0) { const next = mergeFeedCandidateMetadata(merged[existingPosition] ?? null, candidate); if (next !== null) merged[existingPosition] = next; return; } positions.set(candidate.id, merged.length); merged.push(candidate); }; for (const raw of Array.isArray(queue) ? queue : []) add(raw); for (const raw of Array.isArray(incoming) ? incoming : []) add(raw); return merged.slice(0, max); } function scoreFeedCandidate(candidate, recent) { let score = 0; const authorId = candidate.authorId; const author = candidate.author; for (let index = 0; index < recent.length; index += 1) { const item = recent[index]; if (item === void 0) continue; const sameAuthor = authorId !== "" && item.authorId === authorId || author !== "" && item.author === author; if (sameAuthor) score += index === 0 ? 100 : Math.max(10, 40 - index * 3); let overlap = 0; for (const topicId of candidate.topicIds) { if (item.topicIds.includes(topicId)) overlap += 1; } for (const topic of candidate.topics) { if (item.topics.includes(topic)) overlap += 1; } if (overlap > 0) score += overlap * Math.max(4, 18 - index * 2); } return score; } function rankFeedCandidates(candidates, recent) { const normalizedRecent = []; for (const item of Array.isArray(recent) ? recent : []) { const candidate = normalizeFeedCandidate(item); if (candidate !== null) normalizedRecent.push(candidate); } const unique = []; const known = /* @__PURE__ */ new Set(); for (const raw of Array.isArray(candidates) ? candidates : []) { const candidate = normalizeFeedLegacyCandidate(raw); if (candidate === null || known.has(candidate.id)) continue; known.add(candidate.id); unique.push(candidate); } return unique.map((candidate, index) => ({ candidate, index, score: scoreFeedCandidate(candidate, normalizedRecent) })).sort((left, right) => left.score - right.score || left.index - right.index).map((entry) => entry.candidate); } function selectNextFeedCandidate(options = {}) { const currentId = normalizeFeedId(options.currentId); const visited = options.visited instanceof Set ? options.visited : /* @__PURE__ */ new Set(); const seen = options.seen instanceof Set ? options.seen : /* @__PURE__ */ new Set(); const recent = Array.isArray(options.recent) ? options.recent : []; const isBlocked = (raw) => { const candidate = normalizeFeedLegacyCandidate(raw); if (candidate === null) return true; return candidate.id === currentId || visited.has(candidate.id) || seen.has(candidate.id); }; const feedOrder = Array.isArray(options.feedOrder) ? options.feedOrder : []; const currentIndex = feedOrder.findIndex( (item) => normalizeFeedLegacyCandidate(item)?.id === currentId ); const feedPositions = /* @__PURE__ */ new Map(); for (let index = 0; index < feedOrder.length; index += 1) { const id = normalizeFeedLegacyCandidate(feedOrder[index])?.id; if (id !== void 0 && !feedPositions.has(id)) feedPositions.set(id, index); } const isBehindCurrent = (raw) => { if (currentIndex === -1) return false; const id = normalizeFeedLegacyCandidate(raw)?.id; if (id === void 0) return false; const position = feedPositions.get(id); return position !== void 0 && position <= currentIndex; }; const queueRows = Array.isArray(options.queue) ? options.queue : []; const relatedRows = Array.isArray(options.related) ? options.related : []; const sources = [ { source: "feed", candidates: currentIndex === -1 ? [] : feedOrder.slice(currentIndex + 1).filter((item) => !isBlocked(item)) }, { source: "queue", candidates: queueRows.filter((item) => !isBlocked(item) && !isBehindCurrent(item)) }, { source: "related", candidates: relatedRows.filter((item) => !isBlocked(item)) } ]; for (const entry of sources) { if (entry.candidates.length === 0) continue; const ranked = rankFeedCandidates(entry.candidates, recent); const first = ranked[0]; if (first !== void 0) return { source: entry.source, candidate: first }; } return null; } function normalizeFeedDiscoveryLinks(links) { if (!Array.isArray(links)) return null; const candidates = []; const seen = /* @__PURE__ */ new Set(); for (const raw of links) { const candidate = normalizeFeedCandidate(raw); if (candidate === null || seen.has(candidate.id)) continue; seen.add(candidate.id); candidates.push(candidate); } return candidates; } function buildFeedDiscoveryUrl(offset, params) { const safeOffset = Number.isSafeInteger(offset) && offset > 0 ? offset : 0; const query = new URLSearchParams({ pull: "0", offset: String(safeOffset), dw: "660" }); const extra = asRecord(params) ?? {}; for (const [key, value] of Object.entries(extra)) { if (value === void 0 || value === null || value === "") continue; query.set(key, String(value)); } return "https://api.xiaoheihe.cn" + FEED_DISCOVERY_API_PATH + "?" + query.toString(); } function readFeedDiscoveryResponse(data) { const record = asRecord(data); if (record === null) { return { ok: false, links: [], message: "信息流返回内容无法解析" }; } if (record["status"] === "login") { return { ok: false, links: [], message: "登录状态已失效,请先登录小黑盒" }; } if (record["status"] !== "ok") { const message = normalizeFeedText(record["msg"] ?? record["message"], 60) || "信息流接口返回失败"; return { ok: false, links: [], message }; } const result = asRecord(record["result"]); const rawLinks = result === null ? void 0 : result["links"]; const links = normalizeFeedDiscoveryLinks(rawLinks); if (links === null) { return { ok: false, links: [], message: "信息流返回内容无法解析" }; } return { ok: true, links, rawEmpty: Array.isArray(rawLinks) ? rawLinks.length === 0 : false, message: "" }; } function hasDiscoverableNext(status) { const record = asRecord(status); if (record === null) return false; const next = asRecord(record["next"]); if (next === null) return false; return next["source"] !== "related"; } function readFeedBodyJson(response, signal, timeoutMs) { return new Promise((resolve, reject) => { let settled = false; const settle = (callback, value) => { if (settled) return; settled = true; globalThis.clearTimeout(timer); signal?.removeEventListener("abort", onAbort); callback(value); }; const onAbort = () => { settle(reject, signal?.reason || new DOMException("请求已取消", "AbortError")); }; const timer = globalThis.setTimeout(() => { settle(reject, new DOMException("请求超时", "TimeoutError")); }, timeoutMs); if (signal?.aborted === true) { onAbort(); return; } signal?.addEventListener("abort", onAbort, { once: true }); Promise.resolve().then(() => response.json()).then((value) => settle(resolve, value), (error) => settle(reject, error)); }); } async function requestFeedDiscoveryPage(offset, signal, bodyTimeoutMs = FEED_DISCOVERY_BODY_TIMEOUT_MS) { const attempts = []; try { attempts.push( buildFeedDiscoveryUrl(offset, { ...createApiParams(FEED_DISCOVERY_API_PATH), heybox_id: getHeyboxId() }) ); } catch { } attempts.push(buildFeedDiscoveryUrl(offset, { heybox_id: getHeyboxId() })); let message = "信息流请求失败"; const requestOptions = { credentials: "include", cache: "no-store", headers: { accept: "application/json" }, ...signal === void 0 || signal === null ? {} : { signal } }; for (const url of attempts) { try { const response = await fetchWithTimeout(url, requestOptions); if (!response.ok) { message = "信息流请求失败(HTTP " + response.status + ")"; continue; } let rawData; try { rawData = await readFeedBodyJson(response, signal, bodyTimeoutMs); } catch (error) { if (isAbortError(error)) throw error; message = error instanceof DOMException && error.name === "TimeoutError" ? "信息流响应超时,请稍后重试" : "信息流返回内容无法解析"; continue; } const result = readFeedDiscoveryResponse(rawData); if (result.ok) return result; message = result.message; const record = asRecord(rawData); if (record !== null && record["status"] === "login") return result; } catch (error) { if (isAbortError(error)) throw error; message = "网络异常,请稍后重试"; } } return { ok: false, links: [], message }; } var PAGER_CSS = ` :host { position: fixed; inset: 0; display: block; pointer-events: none; z-index: 2147482990; font: 12px/1.5 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; color: var(--heybox-up-text, #14191e); } :host([hidden]) { display: none; } .pager { position: absolute; inset: 0; pointer-events: none; } .btn { position: absolute; top: 50%; display: grid; place-items: center; width: 42px; height: 64px; padding: 0; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .12)); border-radius: 999px; background: var(--heybox-up-surface, #fff); box-shadow: var(--heybox-up-shadow-2, 0 10px 28px rgba(8, 12, 16, .18)); color: var(--heybox-up-muted, #6b7280); font: inherit; cursor: pointer; opacity: .78; transform: translateY(-50%); pointer-events: auto; transition: opacity .16s ease, color .16s ease, background-color .16s ease, border-color .16s ease; } .btn--prev { left: max(10px, calc((100vw - var(--heybox-up-page-w, 1360px)) / 2 - 54px)); } .btn--next { right: max(10px, calc((100vw - var(--heybox-up-page-w, 1360px)) / 2 - 54px)); } .btn:hover:not(:disabled) { opacity: 1; border-color: var(--heybox-up-accent, #6f8db3); background: var(--heybox-up-subtle, rgba(20, 25, 30, .06)); color: var(--heybox-up-text, #14191e); } .btn:active:not(:disabled) { background: var(--heybox-up-raised, #d7dce3); } .btn:disabled { opacity: .3; cursor: default; } .btn:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: 2px; } .btn svg { width: 20px; height: 20px; flex: none; transition: transform .16s ease; } .btn--prev:hover:not(:disabled) svg { transform: translateX(-1px); } .btn--next:hover:not(:disabled) svg { transform: translateX(1px); } .pager-status { position: absolute; top: calc(50% + 46px); right: max(10px, calc((100vw - var(--heybox-up-page-w, 1360px)) / 2 - 84px)); max-width: 180px; margin: 0; padding: 6px 10px; border: 1px solid var(--heybox-up-border, rgba(20, 25, 30, .12)); border-radius: 8px; background: var(--heybox-up-surface, #fff); box-shadow: var(--heybox-up-shadow-2, 0 10px 28px rgba(8, 12, 16, .18)); color: var(--heybox-up-muted, #6b7280); font: inherit; text-align: center; pointer-events: none; } .pager-status[hidden] { display: none; } @media (max-width: 1199px) { .btn { width: 38px; height: 56px; opacity: .64; } .btn--prev { left: 8px; } .btn--next { right: 8px; } } @media (max-width: 1199px) { .pager-status { top: calc(50% + 42px); right: 8px; max-width: 136px; } } @media (max-width: 1080px) { :host { display: none; } } @media (prefers-reduced-motion: reduce) { .btn, .btn svg { transition: none; } } `; // scripts/heybox-up/src/ui/theme.ts var THEME_CLASS = "heybox-up-theme"; var THEME_ATTR = "data-heybox-up-theme"; var THEME_STORAGE_KEY = "heybox-up.theme.v1"; var THEME_ENTRY_ID = "heybox-up-theme-entry"; var THEME_STYLE_ID = "heybox-up-theme-style"; var THEME_MEDIA = window.matchMedia("(prefers-color-scheme: dark)"); var THEME_PRESETS = [ { id: "system", label: "跟随系统", hint: "随系统深浅切换", dark: null, icon: "system", swatch: ["#f5f7f9", "#1b2430"] }, { id: "dark", label: "夜航", hint: "冷灰深色,细边框", dark: true, icon: "dark", swatch: ["#0f1419", "#8fb0cf"] }, { id: "oled", label: "纯黑", hint: "纯黑底,低圆角", dark: true, icon: "oled", swatch: ["#000000", "#a8c0d8"] }, { id: "light", label: "浅色", hint: "站点原色", dark: false, icon: "light", swatch: ["#ffffff", "#eef1f4"] }, { id: "paper", label: "纸页", hint: "暖纸底,衬线标题", dark: false, icon: "paper", swatch: ["#f4efe4", "#8a6f4e"] }, { id: "mist", label: "雾青", hint: "冷雾底,大圆角", dark: false, icon: "mist", swatch: ["#eef3f5", "#4f7f8a"] } ]; var THEME_IDS = THEME_PRESETS.map((preset) => preset.id); function getThemePreset(id) { const found = THEME_PRESETS.find((preset) => preset.id === id); if (found !== void 0) return found; const fallback = THEME_PRESETS[0]; if (fallback === void 0) throw new Error("THEME_PRESETS is empty"); return fallback; } function normalizeThemeMode(value) { return typeof value === "string" && THEME_IDS.includes(value) ? value : "system"; } function resolveTheme(mode, systemDark) { return mode === "system" ? systemDark ? "dark" : "light" : getThemePreset(mode).id; } var currentThemeMode = "system"; var themeEntry = null; var previewToggle = null; var navMotionToggle = null; var readerToggle = null; var feedWidthChips = []; var feedWidthInput = null; var readerLineHeightInput = null; function readThemeMode() { return normalizeThemeMode(localStore.read(THEME_STORAGE_KEY)); } function writeThemeMode(mode) { localStore.write(THEME_STORAGE_KEY, mode); } var THEME_CSS = ` :root.heybox-up-theme { --heybox-up-bg: #fff; --heybox-up-surface: #fff; --heybox-up-subtle: rgba(20, 25, 30, .06); --heybox-up-raised: #d7dce3; --heybox-up-border: rgba(20, 25, 30, .1); --heybox-up-text: #14191e; --heybox-up-text-2: #3c4249; --heybox-up-muted: #6b7280; --heybox-up-on-accent: #fff; --heybox-up-icon: #62676e; --heybox-up-icon-hover: #14191e; --heybox-up-icon-hover-bg: rgba(20, 25, 30, .06); --heybox-up-accent: #6f8db3; --hbu-menu-bg: #fff; --hbu-menu-text: #14191e; --hbu-menu-muted: #6b7280; --hbu-menu-border: rgba(20, 25, 30, .12); --hbu-menu-hover: rgba(20, 25, 30, .06); color-scheme: light; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] { color-scheme: dark; --heybox-up-bg: #0f1419; --heybox-up-surface: #171d24; --heybox-up-subtle: #1e2630; --heybox-up-raised: #26303b; --heybox-up-border: #2d3845; --heybox-up-text: #dce5ef; --heybox-up-text-2: #b4c0ce; --heybox-up-muted: #8593a3; --heybox-up-accent: #8fb0cf; --heybox-up-on-accent: #10161c; --heybox-up-icon: #b4c0ce; --heybox-up-icon-hover: #dce5ef; --heybox-up-icon-hover-bg: rgba(220, 229, 239, .09); --hbu-menu-bg: #171d24; --hbu-menu-text: #dce5ef; --hbu-menu-muted: #8593a3; --hbu-menu-border: #2d3845; --hbu-menu-hover: rgba(220, 229, 239, .09); --hbu-success: #7fc3a2; --hbu-danger: #e0858d; --hbu-warning: #d8b77a; --hb-general-color-bg-0: #0f1419; --hb-general-color-bg-1: #171d24; --hb-general-color-bg-2: #1e2630; --hb-general-color-bg-3: #1e2630; --hb-general-color-bg-4: #171d24; --hb-general-color-bg-5: rgba(23, 29, 36, .9); --hb-general-color-bg-6: rgba(220, 229, 239, .1); --hb-general-color-text-1: #dce5ef; --hb-general-color-text-2: #b4c0ce; --hb-general-color-text-3: #8593a3; --hb-general-color-text-4: #6d7a88; --hb-general-color-text-5: #9dc0e2; --hb-general-color-text-6: #10161c; --hb-general-color-stroke-0: #2d3845; --hb-general-color-stroke-1: #2d3845; --hb-general-color-stroke-2: #26303b; --hb-general-color-stroke-3: #1e2630; --hb-general-color-primary: #8fb0cf; --hb-general-color-success: #7fc3a2; --hb-general-color-danger: #e0858d; --hb-primary: #dce5ef; --hb-primary--value: 220, 229, 239; --hb-primary-dynamic: #dce5ef; --hb-primary-dynamic--value: 220, 229, 239; --hb-neutral-100: #1e2630; --hb-neutral-100--value: 30, 38, 48; --hb-neutral-200: #1e2630; --hb-neutral-200--value: 30, 38, 48; --hb-neutral-300: #171d24; --hb-neutral-300--value: 23, 29, 36; --hb-neutral-400: #26303b; --hb-neutral-400--value: 38, 48, 59; --hb-neutral-500: #2d3845; --hb-neutral-500--value: 45, 56, 69; --hb-neutral-600: #6d7a88; --hb-neutral-600--value: 109, 122, 136; --hb-neutral-700: #8593a3; --hb-neutral-700--value: 133, 147, 163; --hb-neutral-800: #b4c0ce; --hb-neutral-800--value: 180, 192, 206; --el-color-primary: #8fb0cf; --el-color-primary-light-9: #1b2735; --el-color-success: #7fc3a2; --el-color-danger: #e0858d; --el-color-warning: #d8b77a; --el-bg-color: #171d24; --el-bg-color-page: #0f1419; --el-bg-color-overlay: #171d24; --el-fill-color-blank: #171d24; --el-fill-color: #1e2630; --el-fill-color-light: #1e2630; --el-fill-color-lighter: #1e2630; --el-fill-color-extra-light: #1e2630; --el-text-color-primary: #dce5ef; --el-text-color-regular: #b4c0ce; --el-text-color-secondary: #8593a3; --el-text-color-placeholder: #8593a3; --el-text-color-disabled: #6d7a88; --el-border-color: #2d3845; --el-border-color-light: #2d3845; --el-border-color-lighter: #2d3845; --el-border-color-extra-light: #26303b; --el-border-color-dark: #3b4653; --el-border-color-darker: #46515f; --el-disabled-bg-color: #1e2630; --el-disabled-text-color: #8593a3; --el-mask-color: rgba(15, 20, 25, .8); --el-overlay-color-lighter: rgba(0, 0, 0, .6); --el-table-bg-color: #171d24; --el-table-tr-bg-color: #171d24; --el-table-header-bg-color: #1e2630; --el-table-row-hover-bg-color: #1e2630; --el-table-border-color: #2d3845; --el-table-text-color: #dce5ef; --el-table-header-text-color: #b4c0ce; --el-box-shadow: 0 12px 32px rgba(0, 0, 0, .3); --el-box-shadow-light: 0 0 12px rgba(0, 0, 0, .28); --el-box-shadow-lighter: 0 0 6px rgba(0, 0, 0, .24); scrollbar-color: #3b4653 #0f1419; } :root.heybox-up-theme[data-heybox-up-dark="true"] body { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#app, .hb-website__app, .hb-page__app, .hb-layout__main, .hb-layout-main__container, .hb-layout__content) { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.nav, .nav-user .nav-user-popover, .el-popper, .el-dropdown-menu, .el-dialog, .el-drawer, .el-message-box, .el-card, .el-table, .el-pagination, .el-select-dropdown, .el-autocomplete-suggestion, .hb-cpt__selector .selector__pull-list, .page-header__container-bg, .page-header__back-btn, .tab-list--wrapper, .link-tab-list, .hb-cpt-page-header, .hb-cpt__pagination, .hb-cpt__slide-tab, .user-profile-wrapper, .bbs-info, .message-center__main, .message-center__aside-title, .message-center__search-box, .message-home__tabs, .message-content-preview, .message-content-preview__post, .search-result__tab-header, .search-result__tool, .hb-search-result, .hot-search, .price-box, .result-mini-app__item, .result-filter-tag__tag, .hb-bbs-link__header, .hb-bbs-link__content, .link-comment, .link-reply, .comment__comment-header, .topic-link__header, .post__container, .image-text__container, .header-image__item, .bbs-link__related-recommend, .hb-cpt__empty, .qr-section, .app-link-item) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-bbs-community, #page-bbs-link, #page-bbs-list, .page-bbs-community, .page-bbs-link) { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } /* Search page: page and list paint fixed white bands above and below the results. */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list::before, :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list::after { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .list::before, :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .list::after { background-color: var(--heybox-up-bg) !important; } /* Result rows are separated by inline 4px light strips. */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .search-result__space { background-color: transparent !important; } /* The User tab paints a fixed light notice bar above the result list. */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .search-result__middle.user { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .search-result__middle.user::after { color: var(--heybox-up-muted) !important; } /* Horizontal result scroller fades to hard-coded white on the right edge. */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list .hb-cpt__pagination--right { background-image: linear-gradient(to left, var(--heybox-up-surface), var(--heybox-up-surface), transparent) !important; } /* 我的文章(创作者中心):站点整页写死 #fff,深色下逐块补齐,避免只剩外框变黑。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-creator-content-management, #page-creator-content-management-detail) { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-creator-content-management .creator-content-management__header, #page-creator-content-management .creator-content-management__tabs, #page-creator-content-management-detail .content-detail__content-wrapper, #page-creator-content-management-detail .content-detail__section, .com-chart, #creator-preview__frame, .creator-preview-normal, .creator-draft) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-creator-content-management .creator-content-management__tabs::after, #page-creator-content-management .content-management-home__content + .content-management-home__content::after) { background-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-creator-content-management .content-management-home__content:hover, #page-creator-content-management .creator-content-management__tabs .sorter:hover, .creator-content-item__operation-item:hover) { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .creator__data-indicator { background-color: var(--heybox-up-subtle) !important; box-shadow: 0 0 0 .5px var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .creator__data-indicator.active { background-color: var(--heybox-up-surface) !important; box-shadow: inset 0 0 0 1px var(--heybox-up-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-creator-content-management .creator-content-management__header, #page-creator-content-management-detail .content-detail__title, #page-creator-content-management-detail .content-detail__section-header, .data-indicator__data, .com-chart .nav, .com-chart .selection .text, .aside-miniprogram__header, .aside-miniprogram__item--name) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.data-indicator__title, #page-creator-content-management .creator-content-management__tabs .sorter, #page-topic-link .topic-link__sorter) { color: var(--heybox-up-text-2) !important; } /* 频道页:整页浅灰底 + 三块白卡,连同上下固定色条一起换底。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-topic-link { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-topic-link::before, :root.heybox-up-theme[data-heybox-up-dark="true"] #page-topic-link::after { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-topic-link .topic-link__panel, #page-topic-link .topic-link__header, #page-topic-link .topic-link__filter-row, #page-topic-link .topic-link__game-detail, .topic-aside__topic-indicator, .topic__aside-miniprogram) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-topic-link .topic-link__main::after { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] .topic__aside-miniprogram .aside-miniprogram__item, :root.heybox-up-theme[data-heybox-up-dark="true"] #page-topic-link .topic-link__splitline::after { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .topic__aside-miniprogram .aside-miniprogram__item:hover { background-color: var(--heybox-up-surface) !important; } /* 兜底扫描挂上的类:只改底色、描边与文字色,不动布局。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] .heybox-up-dark-flat { background-color: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .heybox-up-dark-flat-subtle { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .heybox-up-dark-edge { border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .heybox-up-dark-ink { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.hb-bbs-home, .bbs-home__content-list, .bbs-home__content-item, .bbs-home__topic-item, .bbs-community-hot-topic, .bbs-community__search-module, .right-side-default, .hb-cpt__scroll-list, #page-bbs-link .list, .link-tags__tag-item, .hb-cpt__popover-menu, .hb-cpt__report, .route-to-app__qrcode, .scroll-list__top-btn, .hb-cpt__loading.circle, .search-history, .search__pull-list) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.hb-cpt__image--default, .hb-cpt__image--placeholder, .link-reply__input-wrapper) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .bbs-new-style-bottom__rich-stack { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .bbs-new-style-bottom__rich-node { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-user-favour > .content > .list { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] #page-user-favour .user-favour__list, :root.heybox-up-theme[data-heybox-up-dark="true"] #page-user-favour .user-favour__list > * { background-color: transparent !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.user-favour__item, .favour-item, .favour-card, .list-content, .bbs-list-content, .bbs-link, .link-item, .hot-topic-item, .creator-content-item, .comment-message-item, .message-list-item.active) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.bbs-content__title, .bbs-content__content, .post__title, .post__content, .image-text__title, .image-text__content, .comment-item__content, .children-item__comment-content, .list-content__username, .comment-item__username, .hot-topic-item__name, .nav-item, .view-header__nav-item, .user-profile-wrapper .name, .bbs-info-item .value, .back-btn__text, .page-header__username, .link-user__username, .section-title__content, .content-tag-text, .slide-tab__tab-label, .slide-tab__tab-cnt, .info-box__username, .hb-editor, .message-list-item__title, .comment-message-item__username, .comment-message-item__text, .message-content-preview__reply-text, .message-content-preview__text, .creator-content-item__title, .editor__more-info-title, .creator-editor__form-label, .frame-top-title, .bbs-link-section-title-text, .user-favour__title, .game-name, .result-mini-app__item--name, .result-tool__content--name) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.list-content__time, .bbs-list-content__time, .post__time, .image-text__time, .comment-item__time, .creator-content-item__create_at, .creator-content-item__type, .creator-content-item__data-item, .message-list-item__time, .user-favour__empty, .scroll-list__no-more-desc, .page-header__meta, .list-content__meta) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.bbs-home__topic-name, .hot-topic__header, .related-recommend__container--header, .link-item__title, .qr-section .title, .qr-section span, .app-link-item span, .search-pull__found-item, .search-pull__history-item, .hb-cpt__popover-menu-item.plain) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.link-item__desc, .bbs-new-style-bottom__comment, .search-pull__section-title, .search-history__item) { color: var(--heybox-up-muted) !important; } /* Search suggestions: site fills history chips light and hot rank labels dark; retheme both. */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-pull__history-item, .search-history__item) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-pull__history-item:hover, .search-history__item:hover) { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search-history__item::before { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search-pull__section-title { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__hot-rank .hot-rank__tab-item { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__hot-rank .hot-rank__tab-item.active { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__hot-rank .hot-rank__list-item .hot-rank__list-item-game-name { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__hot-rank .hot-rank__list-item:hover { background-color: var(--heybox-up-subtle) !important; } /* Search results: the site paints matched terms in a fixed #004b96, unreadable on the themed surface. */ :root.heybox-up-theme[data-heybox-up-dark="true"] #page-bbs-list em { color: var(--heybox-up-accent) !important; } /* Search autocomplete opens in a body-level Element Plus popper; theme the popper itself and every inner wrapper so suggestions never fall back to black on white. */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) { border: 1px solid var(--heybox-up-border) !important; background: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; box-shadow: 0 18px 48px rgba(0, 0, 0, .48) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) :is(.el-autocomplete-suggestion, .el-autocomplete-suggestion__wrap, .el-autocomplete-suggestion__list) { background: transparent !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list li { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list li:hover, :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list li.highlighted, :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list li[aria-selected="true"] { background: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list .item-text { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) .el-autocomplete-suggestion__list :is(em, b, strong) { color: var(--heybox-up-accent) !important; font-style: normal; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search-input-popper, .el-autocomplete__popper) :is(.el-popper__arrow, .popper__arrow)::before { border-color: var(--heybox-up-border) !important; background: var(--heybox-up-surface) !important; } /* The 2026 search box renders its own suggestion list instead of the Element Plus popper. Names are hard-coded to #14191e while the panel inherits the themed surface, so every row, wrapper, and matched fragment needs an explicit readable color. */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.search__pull-list .search__suggestion, .search__pull-list .suggestion__item) { background-color: transparent !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list :is(.suggestion__item-content, .suggestion__item-name) { background-color: transparent !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list .suggestion__item-desc { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list .suggestion__item-icon { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list :is(.suggestion__item:hover, .suggestion__item:focus-visible, .suggestion__item.active, .suggestion__item.is-active, .suggestion__item.selected, .suggestion__item.highlighted) { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list .suggestion__item:focus-visible { outline: 2px solid var(--heybox-up-accent) !important; outline-offset: -2px; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list .suggestion__item[aria-selected="true"] { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .search__pull-list .suggestion__item :is(em, b, strong, mark) { background-color: transparent !important; color: var(--heybox-up-accent) !important; font-style: normal; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.post__content, .image-text__content) :where(p, span, b, strong, em, i, li, h1, h2, h3, h4, h5, h6, blockquote, .text) { color: inherit !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.post__content, .image-text__content) a { color: var(--heybox-up-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(input:not([type="checkbox"]):not([type="radio"]), textarea, select) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(input, textarea)::placeholder { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] option { background-color: var(--heybox-up-surface); color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.el-input__wrapper, .el-textarea__inner, .el-select__wrapper, [contenteditable="true"]) { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; box-shadow: 0 0 0 1px var(--heybox-up-border) inset !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.tab-item.active, .slide-tab__tab-item.active, .slide-tab__tab-item.active .slide-tab__tab-label, .nav-link.nav-link--active, .nav-link--active) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .result-filter-tag__tag.active { background-color: var(--heybox-up-accent) !important; border-color: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.page-header__back-btn, .hb-cpt__pagination, .hb-cpt__slide-tab) { border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.el-table th.el-table__cell, .el-table tr) { background-color: transparent !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.el-table, .el-table__inner-wrapper, .el-table__body-wrapper, .el-table__header-wrapper) { background-color: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.tab-active-block, .slide-tab-tab__bar) { background-color: var(--heybox-up-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.hb-cpt__button.primary, .hb-cpt__button--primary, .editor-publish__btn.main-btn) { background-color: var(--heybox-up-accent) !important; background-image: none !important; border-color: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.nav-user-popover-item:hover, .message-list-item:hover, .link-comment__comment-item:hover, .creator-content-item__operation-item:hover) { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.bbs-info-item, .message-center__aside, .message-center__content, .comment-message-item, .link-comment__comment-item, .creator-content-item, .editor-tabs, .cpt-qa__row, .cpt-qa__tabs, .hb-cpt__editor-title, .editor-image-text__main-content, .editor-image-wrapper__box, .creator-editor__form-item) { border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] [data-mask$="-bg"] { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] [data-mask$="-edge"] { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .nav { --nav-background: rgba(15, 20, 25, .94); --nav-shadow: 0 6px 16px rgba(0, 0, 0, .28); --nav-link-color: #8593a3; --nav-link-hover-color: #dce5ef; --nav-link-active-color: #dce5ef; --nav-link-hover-bg: transparent; --nav-link-active-bg: transparent; --publish-color: #10161c; --publish-bg: linear-gradient(46deg, #9db9d3, #8fb0cf); --publish-hover-shadow: 0 8px 18px rgba(0, 0, 0, .3); --nav-user-bg: rgba(220, 229, 239, .08); --nav-user-bg-hover: rgba(220, 229, 239, .14); --nav-user-divider-color: rgba(220, 229, 239, .12); --nav-user-name-color: #dce5ef; --nav-user-arrow-color: rgba(220, 229, 239, .72); --nav-logo-color: #dce5ef; --nav-language-color: #8593a3; --nav-language-border-color: rgba(133, 147, 163, .5); background: var(--nav-background) !important; box-shadow: var(--nav-shadow) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .nav :is(.nav-link, .nav-icon) { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .nav button.nav-link.nav-link--active { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .nav-content-left-logo :is(path, polygon, rect) { fill: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.nav-user-popover-name, .nav-user-popover-item-text) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.nav-user-popover-id, .nav-user-popover-item-icon) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .nav-user-popover-divider { background-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(button, a, input, textarea, select, summary, [tabindex], [role="button"], [contenteditable="true"]):focus-visible { outline: 2px solid var(--heybox-up-accent) !important; outline-offset: 2px !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.comment-item__tag-line, .comment-item__writer-like, .children-item__reply-to, .children-item__create-time, .children-item__ip, .info-box__create-time, .info-box__ip, .like-box, .load-all__text, .link-reply__placeholder, .link-reply__operation-desc) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.comment-children__load-all, .link-reply__editor, .link-reply__operation-item, .hb-toast, .el-message) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .hb-loading-spinner { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.el-message, .el-message__content) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .el-image-viewer__wrapper { background-color: rgba(0, 0, 0, .88) !important; } /* 登录表单保持官方浅色,避免第三方登录控件在深色下失去可读性。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] .website-login { color-scheme: light; background-color: #fff !important; color: #14191e !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .website-login :is(input, textarea, select) { background-color: transparent !important; border-color: #e8eaed !important; color: #14191e !important; } /* 文章编辑器:站点把底色写在多层容器里,深色、纸页与雾青预设需要整块覆盖。 */ :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor > .content > .list, :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.editor-tabs, .editor-article__main-content, .editor-image-text__main-content, .editor-video__main-content, .hb-cpt__editor-title, .editor__menu, .editor__more-info, .creator-editor__form-wrapper, .cpt-editor__publish, .cpt-qa), :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor-article__main-content { box-shadow: 0 0 0 1px var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu { box-shadow: inset 0 0 0 1px var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor-article__main-content :is(.hb-editor, .ProseMirror) { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme:is([data-heybox-up-theme="paper"], [data-heybox-up-theme="mist"]) #page-creator-editor .editor-article__main-content :is(.hb-editor, .ProseMirror) { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.hb-editor, .ProseMirror) :where(p, span, b, strong, em, i, u, s, li, h1, h2, h3, h4, h5, h6, blockquote, td, th) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.hb-editor, .ProseMirror) :is(a, .prose-link) { color: var(--heybox-up-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor-title__cnt-indicator { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor-publish__btn.main-btn { background-color: var(--heybox-up-accent) !important; background-image: none !important; border-color: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) :is(.hb-cpt__button.primary, .hb-cpt__button--primary) { background-color: var(--heybox-up-accent) !important; background-image: none !important; border-color: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.editor__topic-item, .editor__hashtag-item, .editor__add-btn, .selector__box, .editor-publish__btn.sub-btn, .cpt-qa__tab, .cpt-qa__key) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.selector__pull-list, .editor-menu-more__list) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .selector__pull-list-item { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.selector__pull-list-item:hover, .selector__pull-list-item.active) { background-color: var(--heybox-up-raised) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .selector__box { box-shadow: 0 0 0 1px var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .switch__button { background-color: var(--heybox-up-raised) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .switch__button.active { background-color: var(--heybox-up-accent) !important; border-color: var(--heybox-up-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .switch__button-indicator { background-color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .switch__button.active .switch__button-indicator { background-color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.editor-form__more-info-title, .creator-editor__form-header, .selector__active, .cpt-qa__head-title, .cpt-qa__row-name) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.topic-item__text, .hashtag-item__text, .editor-form__more-info-desc, .editor__more-info-desc, .cpt-qa__head-sub) { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.switch__true-label, .switch__false-label) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.switch__true-label.active, .switch__false-label.active) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .cpt-form-split-line { background-color: var(--heybox-up-border) !important; } /* Editor dividers and toolbar icons: site paints light 4px / 1px strips and dims icon fills. */ :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .creator-editor__form-wrapper::before { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .cpt-editor__publish::after, :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .hb-cpt__editor-title::after, :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor-menu__item-splitline { background-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .hb-icon { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu__item--content-inactive .hb-icon svg { opacity: .8 !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu__item.forbid { opacity: .8 !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu-more__list-item { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu-more__list-item .hb-icon { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu-more__list-item svg { opacity: .9 !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor .editor__menu .editor-menu-more__list-item:hover { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) [data-mask$="-bg"] { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) [data-mask$="-edge"] { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.hb-cpt__image--default, .hb-cpt__image--placeholder) { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.editor-image-wrapper__box, .video-uploader__unload) { background-color: var(--heybox-up-surface) !important; background-image: none !important; border-color: var(--heybox-up-border) !important; box-shadow: 0 0 0 .5px var(--heybox-up-border) !important; color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #page-creator-editor :is(.editor-image-text__main-content, .editor-video__main-content) { box-shadow: 0 0 0 1px var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.creator-preview-normal, .bbs-link) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.bbs-link-section-author, .bbs-link-section-tags) { background-color: transparent !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-author-name { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-author-time { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-author-follow { background-color: var(--heybox-up-accent) !important; background-image: none !important; border-color: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-author-follow :is(i, span, svg, use) { color: var(--heybox-up-on-accent) !important; fill: var(--heybox-up-on-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.bbs-link-article-content, .hb-article) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.bbs-link-article-content, .hb-article) :where(p, span, b, strong, em, i, li, h1, h2, h3, h4, h5, h6, blockquote, .text, .main-title) { color: var(--heybox-up-text) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.bbs-link-article-content, .hb-article) :is(a, .prose-link) { color: var(--heybox-up-accent) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .img-desc { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-tags-item { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame .bbs-link-section-tags-item-text { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) #creator-preview__frame :is(.bbs-link-section-bottom-info, .bbs-link-section-bottom-info-source) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) .cpt-right-side :is(.footer-info, .footer-row, .report-text, .copyright-text) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme:not([data-heybox-up-theme="light"]) .cpt-right-side .footer-link { color: var(--heybox-up-text-2) !important; } /* 纯黑预设:黑底、低圆角、无阴影,仍复用全部深色覆盖规则。 */ :root.heybox-up-theme[data-heybox-up-theme="oled"] { --heybox-up-bg: #000; --heybox-up-surface: #08090b; --heybox-up-subtle: #101216; --heybox-up-raised: #161a20; --heybox-up-border: #1d2026; --heybox-up-text: #e6ebf2; --heybox-up-text-2: #b8c2cf; --heybox-up-muted: #9aa4b2; --heybox-up-accent: #a8c0d8; --heybox-up-on-accent: #05070a; --hbu-menu-bg: #08090b; --hbu-menu-text: #e6ebf2; --hbu-menu-muted: #9aa4b2; --hbu-menu-border: #1d2026; --hbu-menu-hover: rgba(230, 235, 242, .07); --hb-general-color-bg-0: #000; --hb-general-color-bg-1: #08090b; --hb-general-color-bg-2: #101216; --hb-general-color-bg-3: #101216; --hb-general-color-bg-4: #08090b; --hb-general-color-text-1: #e6ebf2; --hb-general-color-text-2: #b8c2cf; --hb-general-color-text-3: #9aa4b2; --hb-general-color-stroke-0: #1d2026; --hb-general-color-stroke-1: #1d2026; --hb-general-color-stroke-2: #161a20; --hb-general-color-primary: #a8c0d8; --el-color-primary: #a8c0d8; --el-color-primary-light-9: #141a22; --el-bg-color: #08090b; --el-bg-color-page: #000; --el-bg-color-overlay: #08090b; --el-fill-color-blank: #08090b; --el-fill-color: #101216; --el-fill-color-light: #101216; --el-fill-color-lighter: #101216; --el-fill-color-extra-light: #101216; --el-border-color: #1d2026; --el-border-color-light: #1d2026; --el-border-color-lighter: #1d2026; --el-table-bg-color: #08090b; --el-table-tr-bg-color: #08090b; --el-table-header-bg-color: #101216; --el-table-row-hover-bg-color: #101216; --el-table-border-color: #1d2026; --el-mask-color: rgba(0, 0, 0, .86); --el-box-shadow: none; --el-box-shadow-light: none; scrollbar-color: #2a2e35 #000; } :root.heybox-up-theme[data-heybox-up-theme="oled"] .nav { --nav-background: #000; --nav-shadow: none; --publish-color: #05070a; --publish-bg: #a8c0d8; --nav-user-bg: rgba(230, 235, 242, .07); --nav-user-bg-hover: rgba(230, 235, 242, .13); } :root.heybox-up-theme[data-heybox-up-theme="oled"] :is(.bbs-home__content-item, .bbs-home__topic-item, .list-content, .user-favour__item, .post__container, .image-text__container, .link-comment, .hot-search, .right-side-default, .hb-bbs-link__header, .hb-bbs-link__content, .search-result__tool, .result-mini-app__item, .price-box, .el-card, .el-dialog, .el-drawer, .el-message-box, .el-popper, .el-dropdown-menu) { border-radius: 4px !important; box-shadow: none !important; } /* 纸页预设:暖纸底、衬线标题、细边扁平卡片。 */ :root.heybox-up-theme[data-heybox-up-theme="paper"] { --heybox-up-bg: #f4efe4; --heybox-up-surface: #fffdf7; --heybox-up-subtle: #efe8d9; --heybox-up-raised: #e7ddc9; --heybox-up-border: #ddd2bd; --heybox-up-text: #2f2a22; --heybox-up-text-2: #4a4237; --heybox-up-muted: #7c7160; --heybox-up-accent: #8a6f4e; --heybox-up-icon: #7c7160; --heybox-up-icon-hover: #2f2a22; --heybox-up-icon-hover-bg: rgba(47, 42, 34, .07); --hbu-menu-bg: #fffdf7; --hbu-menu-text: #2f2a22; --hbu-menu-muted: #7c7160; --hbu-menu-border: #ddd2bd; --hbu-menu-hover: rgba(47, 42, 34, .06); --hb-general-color-bg-0: #f4efe4; --hb-general-color-bg-1: #fffdf7; --hb-general-color-bg-2: #efe8d9; --hb-general-color-bg-4: #fffdf7; --hb-general-color-text-1: #2f2a22; --hb-general-color-text-2: #4a4237; --hb-general-color-text-3: #7c7160; --hb-general-color-stroke-0: #ddd2bd; --hb-general-color-stroke-1: #ddd2bd; --hb-general-color-primary: #8a6f4e; --el-color-primary: #8a6f4e; --el-bg-color: #fffdf7; --el-bg-color-page: #f4efe4; --el-bg-color-overlay: #fffdf7; --el-fill-color-blank: #fffdf7; --el-fill-color-light: #efe8d9; --el-text-color-primary: #2f2a22; --el-text-color-regular: #4a4237; --el-text-color-secondary: #7c7160; --el-border-color: #ddd2bd; --el-border-color-light: #ddd2bd; scrollbar-color: #cfc2aa #f4efe4; } /* 雾青预设:冷雾底、大圆角、柔和浮层。 */ :root.heybox-up-theme[data-heybox-up-theme="mist"] { --heybox-up-bg: #eef3f5; --heybox-up-surface: #fbfdfe; --heybox-up-subtle: #e4ecef; --heybox-up-raised: #dbe7eb; --heybox-up-border: #d3e0e5; --heybox-up-text: #1f2c33; --heybox-up-text-2: #3e5560; --heybox-up-muted: #6f8792; --heybox-up-accent: #4f7f8a; --heybox-up-icon: #6f8792; --heybox-up-icon-hover: #1f2c33; --heybox-up-icon-hover-bg: rgba(31, 44, 51, .07); --hbu-menu-bg: #fbfdfe; --hbu-menu-text: #1f2c33; --hbu-menu-muted: #6f8792; --hbu-menu-border: #d3e0e5; --hbu-menu-hover: rgba(31, 44, 51, .06); --hb-general-color-bg-0: #eef3f5; --hb-general-color-bg-1: #fbfdfe; --hb-general-color-bg-2: #e4ecef; --hb-general-color-bg-4: #fbfdfe; --hb-general-color-text-1: #1f2c33; --hb-general-color-text-2: #3e5560; --hb-general-color-text-3: #6f8792; --hb-general-color-stroke-0: #d3e0e5; --hb-general-color-stroke-1: #d3e0e5; --hb-general-color-primary: #4f7f8a; --el-color-primary: #4f7f8a; --el-bg-color: #fbfdfe; --el-bg-color-page: #eef3f5; --el-bg-color-overlay: #fbfdfe; --el-fill-color-blank: #fbfdfe; --el-fill-color-light: #e4ecef; --el-text-color-primary: #1f2c33; --el-text-color-regular: #3e5560; --el-text-color-secondary: #6f8792; --el-border-color: #d3e0e5; --el-border-color-light: #d3e0e5; scrollbar-color: #b9ccd3 #eef3f5; } :root.heybox-up-theme:is([data-heybox-up-theme="paper"], [data-heybox-up-theme="mist"]) body { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme:is([data-heybox-up-theme="paper"], [data-heybox-up-theme="mist"]) :is(#app, main, .hb-website__app, .hb-page__app, .hb-layout__main, .hb-layout-main__container, .hb-layout__content) { background-color: var(--heybox-up-bg) !important; color: var(--heybox-up-text); } :root.heybox-up-theme:is([data-heybox-up-theme="paper"], [data-heybox-up-theme="mist"]) :is(.nav, .nav-user .nav-user-popover, .el-popper, .el-dropdown-menu, .el-dialog, .el-drawer, .el-message-box, .hb-cpt__selector .selector__pull-list, .page-header__container-bg, .tab-list--wrapper, .link-tab-list, .hb-cpt-page-header, .user-profile-wrapper, .bbs-info, .message-center__main, .message-center__aside-title, .message-center__search-box, .message-home__tabs, .message-content-preview, .message-content-preview__post, .search-result__tab-header, .hb-search-result, .hb-bbs-link__header, .hb-bbs-link__content, .link-comment, .link-reply, .comment__comment-header, .topic-link__header, .post__container, .image-text__container, .bbs-link__related-recommend, .hb-cpt__empty, .qr-section, .app-link-item, .bbs-home__content-item, .bbs-home__topic-item, .list-content, .user-favour__item, #page-bbs-link .list, .hot-search, .right-side-default, .search-result__tool, .result-mini-app__item, .price-box, .result-filter-tag__tag) { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } :root.heybox-up-theme:is([data-heybox-up-theme="paper"], [data-heybox-up-theme="mist"]) .nav { --nav-link-color: var(--heybox-up-muted); --nav-link-hover-color: var(--heybox-up-text); --nav-link-active-color: var(--heybox-up-text); --nav-link-hover-bg: transparent; --nav-link-active-bg: transparent; --publish-color: #fff; --publish-bg: var(--heybox-up-accent); --nav-user-bg: var(--heybox-up-subtle); --nav-user-bg-hover: var(--heybox-up-raised); --nav-user-name-color: var(--heybox-up-text); --nav-user-divider-color: var(--heybox-up-border); --nav-logo-color: var(--heybox-up-text); } :root.heybox-up-theme[data-heybox-up-theme="paper"] .nav { --nav-background: rgba(244, 239, 228, .95); --nav-shadow: 0 1px 0 #ddd2bd; } :root.heybox-up-theme[data-heybox-up-theme="mist"] .nav { --nav-background: rgba(251, 253, 254, .84); --nav-shadow: 0 8px 24px rgba(47, 74, 84, .08); backdrop-filter: blur(14px) saturate(1.1); } :root.heybox-up-theme[data-heybox-up-theme="paper"] :is(.bbs-home__content-item, .bbs-home__topic-item, .list-content, .user-favour__item, .post__container, .image-text__container, .link-comment, .hot-search, .right-side-default, .hb-bbs-link__header, .hb-bbs-link__content, .search-result__tool, .result-mini-app__item, .price-box, .el-card, .el-dialog, .el-drawer, .el-message-box, .el-popper, .el-dropdown-menu) { border-radius: 3px !important; box-shadow: none !important; } :root.heybox-up-theme[data-heybox-up-theme="mist"] :is(.bbs-home__content-item, .bbs-home__topic-item, .list-content, .user-favour__item, .post__container, .image-text__container, .link-comment, .hot-search, .right-side-default, .hb-bbs-link__header, .hb-bbs-link__content, .search-result__tool, .result-mini-app__item, .price-box, .el-card, .el-dialog, .el-drawer, .el-message-box, .el-popper, .el-dropdown-menu) { border-radius: 16px !important; box-shadow: 0 6px 20px rgba(47, 74, 84, .08) !important; } :root.heybox-up-theme[data-heybox-up-theme="paper"] :is(.post__title, .image-text__title, .bbs-content__title, .bbs-link-section-title-text, .section-title__content, .user-favour__title, .related-recommend__container--header) { font-family: Georgia, "Songti SC", "Noto Serif CJK SC", serif; letter-spacing: .01em; } :root.heybox-up-theme[data-heybox-up-theme="paper"] :is(.post__content, .image-text__content) a { color: var(--heybox-up-accent); text-decoration: underline; text-underline-offset: 2px; } /* 版式:定宽阅读栏 + 卡片化信息流。站点通栏列表每行超过 90 字,长读易疲劳。 */ @media (max-width: 799px) { /* 站点给 #app 写死 800px 下限;实际手机宽度会被裁切,这里恢复单列收缩。 */ #app { min-width: 0 !important; width: 100% !important; } } :root { --heybox-up-page-w: 1360px; /* 中间栏占视口百分比,可在齿轮设置菜单里调;上限避免超宽屏上正文行过长。 */ --heybox-up-feed-pct: 56; /* Body line height multiplier, adjustable in the settings menu or the reader rail panel. */ --heybox-up-reading-line-height: 1.7; --heybox-up-feed-w: min(calc(var(--heybox-up-feed-pct) * 1vw), 1180px); --heybox-up-radius-card: 14px; --heybox-up-gap: 12px; --heybox-up-card-pad: 20px; --heybox-up-shadow-1: 0 1px 2px rgba(16, 24, 40, .05); --heybox-up-shadow-2: 0 14px 34px rgba(16, 24, 40, .14); } :root.heybox-up-theme[data-heybox-up-theme="paper"] { --heybox-up-radius-card: 4px; --heybox-up-shadow-1: none; --heybox-up-shadow-2: 0 10px 24px rgba(47, 42, 34, .12); } :root.heybox-up-theme[data-heybox-up-theme="mist"] { --heybox-up-radius-card: 18px; --heybox-up-shadow-1: 0 4px 14px rgba(47, 74, 84, .07); --heybox-up-shadow-2: 0 16px 36px rgba(47, 74, 84, .14); } :root.heybox-up-theme[data-heybox-up-theme="oled"] { --heybox-up-radius-card: 8px; --heybox-up-shadow-1: none; --heybox-up-shadow-2: 0 14px 34px rgba(0, 0, 0, .72); } @media (min-width: 1200px) { #app > main { max-width: var(--heybox-up-page-w) !important; padding-left: 24px !important; padding-right: 24px !important; } #app > main > section, #app > main > .layout-normal { max-width: 100% !important; } } /* 社区首页:移除热门社区/二维码右栏,信息流量成一条阅读栏,宽度够放两张图也不拉长正文行。 */ #page-bbs-community .cpt-right-side.right { display: none !important; } #page-bbs-community .content { width: auto !important; max-width: var(--heybox-up-page-w) !important; margin: 0 auto !important; justify-content: center !important; } #page-bbs-community main.list { width: auto !important; max-width: var(--heybox-up-feed-w) !important; flex: 0 1 var(--heybox-up-feed-w) !important; min-width: 0; } /* 搜索栏跟随阅读栏宽度居中,避免页头比信息流每侧多出 176px。 */ #page-bbs-community .bbs-community__search-module { width: min(var(--heybox-up-feed-w), 100%) !important; max-width: 100% !important; margin-left: auto !important; margin-right: auto !important; } /* 搜索建议加载期间的空壳会留下 26px 浮层,截获首张帖子卡片的点击;无内容时直接收起。 */ #page-bbs-community .search__pull-list:has(> .search__suggestion:empty) { display: none !important; } @media (min-width: 1200px) { body.heybox-up-rail-open #page-bbs-community .content { justify-content: flex-start !important; } body.heybox-up-rail-open #page-bbs-community main.list { margin-left: 0 !important; margin-right: auto !important; } } #page-bbs-community .hb-bbs-home, #page-bbs-community .bbs-home__content-list { width: auto !important; max-width: none !important; flex: 1 1 auto !important; min-width: 0; } /* 卡片化:帖子之间留间隙与细边,滚动时条目边界一眼可辨。 */ #page-bbs-community .bbs-home__content-list { display: flex !important; flex-direction: column; gap: var(--heybox-up-gap); padding: 4px 0 28px !important; } #page-bbs-community .bbs-home__content-item { margin: 0 !important; padding: 0 !important; border: 1px solid var(--heybox-up-border) !important; border-radius: var(--heybox-up-radius-card) !important; background-color: var(--heybox-up-surface) !important; box-shadow: var(--heybox-up-shadow-1); overflow: hidden; transition: border-color .16s ease, box-shadow .16s ease; } #page-bbs-community .bbs-home__content-item:hover { border-color: var(--heybox-up-raised) !important; box-shadow: var(--heybox-up-shadow-2); } #page-bbs-community .bbs-home__content-item > a.hb-cpt__bbs-list-content { display: block; padding: var(--heybox-up-card-pad) !important; background: transparent !important; } #page-bbs-community :is(.hb-bbs-home__feed-splitline, .hb-bbs-home__splitline)::before, #page-bbs-community :is(.hb-bbs-home__feed-splitline, .hb-bbs-home__splitline)::after { background: transparent !important; } #page-bbs-community .bbs-home__topic-list-wrapper { margin: 0 0 var(--heybox-up-gap) !important; padding: 10px 14px !important; border: 1px solid var(--heybox-up-border) !important; border-radius: var(--heybox-up-radius-card) !important; background-color: var(--heybox-up-surface) !important; overflow: hidden; } /* 卡片内排版:标题 17/1.5,正文 15/1.72,元信息压到 13px,层级靠字号与颜色拉开。 */ #page-bbs-community .bbs-content__title { margin: 10px 0 6px !important; font-size: 17px !important; line-height: 1.5 !important; font-weight: 600; letter-spacing: .005em; } #page-bbs-community .bbs-content__content { font-size: 15px !important; line-height: 1.72 !important; color: var(--heybox-up-text-2) !important; } #page-bbs-community .bbs-list-content__header { gap: 8px; } #page-bbs-community .list-content__username { font-size: 13px !important; font-weight: 500; } #page-bbs-community .bbs-content__imgs-wrapper { margin-top: 10px !important; } #page-bbs-community .bbs-content__image { border-radius: 10px !important; } /* 作者行、更多按钮与图片计数是卡片里最小的可点区域,统一抬到 24px 与 11px。 */ :root.heybox-up-theme :is(#page-bbs-community .header__user, #page-topic-link .header__user) { display: inline-flex; align-items: center; min-height: 24px; } :root.heybox-up-theme .list-cotent__operation-btn { display: inline-grid; place-items: center; width: 24px !important; height: 24px !important; } :root.heybox-up-theme #page-topic-link .topic-link__sorter { display: inline-flex; align-items: center; min-height: 28px; } :root.heybox-up-theme .bbs-content__image-cnt { font-size: 11px !important; } #page-bbs-community .bbs-new-style-bottom { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--heybox-up-border); } /* 窄屏:卡片化会吃掉内边距,收回固定宽度与内边距,信息流改用整幅。 */ @media (max-width: 1100px) { :root { --heybox-up-feed-w: min(960px, 100%); --heybox-up-card-pad: 16px; } } /* 评论预览侧栏:展开时由桌面栅格为预览保留真实宽度,侧栏本身在 Shadow DOM 内自绘。 */ :root { --heybox-up-rail-w: 320px; } @media (max-width: 1399px) { :root { --heybox-up-rail-w: 288px; } } #page-bbs-community :is(.bbs-new-style-bottom__comment, .content-list__comment) { cursor: pointer; } /* 评论排序只改 order,不动 DOM,避免打断站点自身的列表更新。 */ #page-bbs-link .link-comment__list { display: flex !important; flex-direction: column; } /* 帖子详情:正文纸张 + 原生评论侧栏,两块各自成卡片,中间留 24px 空隙。 */ @media (min-width: 1200px) { #page-bbs-link .content { width: auto !important; max-width: var(--heybox-up-page-w) !important; margin: 0 auto !important; gap: 24px; align-items: flex-start; justify-content: center; } #page-bbs-link .list { max-width: none !important; flex: 1 1 auto !important; min-width: 0; } #page-bbs-link .cpt-right-side.right { display: none !important; } #page-bbs-link .hb-cpt__scroll-list.hb-bbs-link__container { width: auto !important; max-width: none !important; } #page-bbs-link .hb-bbs-link__content { display: grid !important; grid-template-columns: minmax(0, 1fr) 424px; gap: 24px; align-items: start; width: auto !important; max-width: none !important; } #page-bbs-link .hb-bbs-image-text { grid-column: 1; min-width: 0; padding: 28px 32px !important; border: 1px solid var(--heybox-up-border) !important; border-radius: var(--heybox-up-radius-card) !important; background-color: var(--heybox-up-surface) !important; box-shadow: var(--heybox-up-shadow-1); /* 正文与评论各自滚动:正文列吸顶并限高,滚到底不带走整页。 */ position: sticky; top: 88px; max-height: calc(100vh - 112px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-gutter: stable; } #page-bbs-link .link-comment { grid-column: 2; position: sticky; top: 88px; height: calc(100vh - 112px); max-height: calc(100vh - 112px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; width: 100% !important; margin: 0 !important; border: 1px solid var(--heybox-up-border) !important; border-radius: var(--heybox-up-radius-card) !important; background-color: var(--heybox-up-surface) !important; box-shadow: var(--heybox-up-shadow-1); } #page-bbs-link .link-comment .comment__comment-header { top: 0 !important; background-color: var(--heybox-up-surface) !important; } } /* Body: line height comes from the user preference; paragraphs stay under 46em. */ #page-bbs-link :is(.post__title, .image-text__title, .section-title__content) { font-size: 25px !important; line-height: 1.42 !important; font-weight: 700; letter-spacing: -.01em; } #page-bbs-link :is(.post__content, .image-text__content) { font-size: 16px !important; line-height: var(--heybox-up-reading-line-height) !important; } /* The site hard-codes a 25px line height on nested paragraphs; cover body text only so cards and blockquotes keep their own styles. */ #page-bbs-link :is(.post__content, .image-text__content) :is(p, li, ul, ol, .line, .order-line, .text):not([class*="com-"], [class*="com-"] *):not(blockquote, blockquote *) { line-height: var(--heybox-up-reading-line-height) !important; } #page-bbs-link :is(.post__content, .image-text__content) :is(p, blockquote, h1, h2, h3, h4) { max-width: 46em; } #page-bbs-link :is(.post__content, .image-text__content) p { margin: 0 0 .72em !important; } #page-bbs-link :is(.post__content, .image-text__content) h2 { margin: 1.4em 0 .6em !important; font-size: 20px !important; line-height: 1.5 !important; } #page-bbs-link :is(.post__content, .image-text__content) h3 { margin: 1.2em 0 .5em !important; font-size: 17px !important; line-height: 1.55 !important; } #page-bbs-link :is(.post__content, .image-text__content) blockquote { margin: 1em 0 !important; padding: 2px 0 2px 14px !important; border-left: 3px solid var(--heybox-up-accent) !important; color: var(--heybox-up-muted) !important; line-height: 1.8 !important; } #page-bbs-link :is(.post__content, .image-text__content) blockquote :is(p, .text) { line-height: 1.8 !important; } /* The native article styles hard-code dark text on nested paragraphs and ordered-list lines. Cards and blockquotes keep their own colors, matching the line-height exclusion above. */ #page-bbs-link :is(.post__content, .image-text__content) :is(p, li, ol, ul, .line, .order-line, .text):not([class*="com-"], [class*="com-"] *):not(blockquote, blockquote *) { color: var(--heybox-up-text) !important; } #page-bbs-link :is(.post__content, .image-text__content) blockquote, #page-bbs-link :is(.post__content, .image-text__content) blockquote :is(p, .text) { color: var(--heybox-up-muted) !important; } #page-bbs-link :is(.post__content, .image-text__content) :is(img, .hb-cpt__image) { border-radius: 10px !important; } /* 评论区:14px 正文,楼层用细线分隔,嵌套回复保留站点结构只改圆角与间距。 */ #page-bbs-link .link-comment__comment-item { padding: 14px 16px !important; border-top: 1px solid var(--heybox-up-border) !important; } #page-bbs-link :is(.comment-item__content, .children-item__comment-content) { font-size: 14px !important; line-height: 1.7 !important; } #page-bbs-link .link-comment__comment-children { margin-top: 8px !important; border-radius: 10px !important; } @media (max-width: 1199px) { #page-bbs-link .content { flex-direction: column; } #page-bbs-link .list { width: 100% !important; max-width: none !important; } #page-bbs-link .cpt-right-side.right { display: none !important; } #page-bbs-link .hb-cpt__scroll-list.hb-bbs-link__container, #page-bbs-link .hb-bbs-link__content { width: auto !important; max-width: none !important; } #page-bbs-link .hb-bbs-link__content { display: flex; flex-direction: column; } #page-bbs-link .link-comment { width: 100% !important; margin: 16px 0 0 !important; } } /* 站点未覆盖的容器补齐主题底色:嵌套回复块、个人主页列表。 */ :root.heybox-up-theme .link-comment__comment-children { background-color: var(--heybox-up-subtle) !important; border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme #page-user-profile > .content > .list { background-color: var(--heybox-up-surface) !important; border-color: var(--heybox-up-border) !important; color: var(--heybox-up-text); } /* Profile submissions use a near-white 4px divider and dark text colors from the original skin. */ :root.heybox-up-theme #page-user-profile .user-profile-article-item::after { background-color: transparent !important; background-image: none !important; } :root.heybox-up-theme #page-user-profile .article-title .title { color: var(--heybox-up-text) !important; } :root.heybox-up-theme #page-user-profile .bottom-line :is(.time, .comment, .comment span) { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme #page-user-profile .tab-item { color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme #page-user-profile .tab-item.active { color: var(--heybox-up-text) !important; } /* The profile right rail only carries the app QR code, promotions and ICP text. */ #page-user-profile .cpt-right-side.right { display: none !important; } #page-user-profile .content > .list { flex: 1 1 auto !important; width: auto !important; min-width: 0 !important; max-width: none !important; } /* Theme the author hover card without changing its 315x146 box or z-index. */ :root.heybox-up-theme #page-user-profile .name-box .detail-info { background-color: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; box-shadow: 0 0 0 1px var(--heybox-up-border), 0 18px 46px rgba(0, 0, 0, .32) !important; } :root.heybox-up-theme #page-user-profile .name-box .detail-info::after { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme #page-user-profile .name-box .detail-info .main-info { color: var(--heybox-up-text) !important; } :root.heybox-up-theme #page-user-profile .name-box .detail-info .sub-info { color: var(--heybox-up-text-2) !important; } /* 站点用伪元素画页面底色与帖子分隔条,颜色不随变量走,需单独覆盖。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#app > main > section, #page-bbs-community .hb-bbs-home__feed-splitline, #page-bbs-community .hb-bbs-home__splitline)::before, :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#app > main > section, #page-bbs-community .hb-bbs-home__feed-splitline, #page-bbs-community .hb-bbs-home__splitline)::after { background-color: var(--heybox-up-bg) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-bbs-community main.list, #page-bbs-link main.list)::before, :root.heybox-up-theme[data-heybox-up-dark="true"] :is(#page-bbs-community main.list, #page-bbs-link main.list)::after { background-color: var(--heybox-up-surface) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .hb-cpt__moment-list-content::after { background-color: var(--heybox-up-subtle) !important; } /* 站点用伪元素画卡片底部 4px 色带,取色固定为近白,任何主题下都会形成白条。 */ :root.heybox-up-theme .hb-bbs-image-text::after, :root.heybox-up-theme .hb-bbs-post::after, :root.heybox-up-theme .hb-bbs__video::after { background-color: transparent !important; background-image: none !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .link-reply::before, :root.heybox-up-theme[data-heybox-up-dark="true"] .link-comment__comment-item + .link-comment__comment-item:not(:hover)::before, :root.heybox-up-theme[data-heybox-up-dark="true"] .link-comment__comment-children .comment-children__load-all::before, :root.heybox-up-theme[data-heybox-up-dark="true"] .hb-cpt__slide-tab.underline::after, :root.heybox-up-theme[data-heybox-up-dark="true"] .hb-cpt__slide-tab.line::after, :root.heybox-up-theme[data-heybox-up-dark="true"] .related-recommend__link-item--content::before, :root.heybox-up-theme[data-heybox-up-dark="true"] .related-recommend__link-item--topic::before { background-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .link-comment__comment-children .comment-children-item:hover::after { background-color: var(--heybox-up-subtle) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .link-reply .link-reply__main-box .link-reply__input-wrapper::after { border-color: var(--heybox-up-border) !important; } /* 正文内嵌的 com-* 卡片族(游戏/专辑/百科/比赛/模组/角标/等)底色写死为浅色, 深色下会整块发白。统一按容器、描边、主次文字、药丸底、占位块五组覆盖。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.com-game-card, .com-album-card, .com-award-table-card, .com-chatroom-card, .com-league-card, .com-match-card, .com-mod-card, .com-sku-card, .com-wiki-card) { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.com-game-card, .com-album-card, .com-award-table-card, .com-chatroom-card, .com-league-card, .com-match-card, .com-mod-card, .com-sku-card, .com-wiki-card).is-editor-mode, :root.heybox-up-theme[data-heybox-up-dark="true"] .com-card-placeholder { background-color: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .com-card-placeholder svg path { fill: var(--heybox-up-muted) !important; } /* 卡片描边用 1px 边框乘 0.5 缩放绘制,颜色写死,深色下成亮边。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.com-game-card, .com-album-card, .com-award-table-card, .com-chatroom-card, .com-league-card, .com-match-card, .com-mod-card, .com-sku-card, .com-wiki-card)::after { border-color: var(--heybox-up-border) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.com-award-table-card .award-table-row, .com-award-table-card .award-table-row.title, .com-award-table-card .award-table-row:last-of-type) { border-color: var(--heybox-up-border) !important; } /* 主文字:写死 #14191e。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is( .com-game-card .game-info .line-1, .com-game-card .game-info .line-2 .price-box .price-content-box, .com-game-card .game-info .line-2 .price-box .price-content-box .coupon-tip, .com-game-card .game-info .line-2 .follow-box .follow-num, .com-album-card .album-info .line-1 .album-name, .com-award-table-card .award-table-row, .com-chatroom-card .info-section .chatroom-title, .com-league-card .league-info-box .line-1 .league-title, .com-match-card .team-1 .team-name, .com-match-card .team-2 .team-name, .com-match-card .match-info-wrapper .line-2 .aftermatch .team-score.weight, .com-match-card .match-info-wrapper .line-2 .watched, .com-match-card .match-info-wrapper .line-2 .commingsoon, .com-mod-card .mod-card-info .mod-card-title, .com-sku-card .sku-info-box .line-1, .com-sku-card .sku-info-box .line-2 .price-box .price-content-box, .com-wiki-card .info-section .info-line .words-num ) { color: var(--heybox-up-text) !important; } /* 次要文字:写死 #8c9196 与 #64696e。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is( .com-game-card .game-info .line-2 .price-box .price-content-box .release-desc, .com-game-card .game-info .line-2 .price-box .price-content-box .release-time, .com-game-card .game-info .line-2 .price-box .price-content-box .original-price, .com-game-card .game-info .line-2 .follow-box .had-tag, .com-game-card .game-info .line-2 .follow-box i, .com-album-card .delete-desc, .com-album-card .album-info .line-2 .game-num, .com-award-table-card .award-table-row.unfold, .com-chatroom-card .info-section .line-2 .default-desc, .com-chatroom-card .info-section .line-2 .online-count, .com-chatroom-card .info-section .line-2 .room-count, .com-league-card .league-info-box .line-2 .league-date, .com-match-card .match-info-wrapper .line-1, .com-match-card .match-info-wrapper .line-2, .com-match-card .match-info-wrapper .line-3, .com-match-card .match-info-wrapper .line-2 .aftermatch .colon, .com-mod-card .mod-card-info .mod-card-summary, .com-sku-card .sku-info-box .line-2 .price-box .price-content-box .original-price, .com-wiki-card .info-section .info-line .words-num-desc, .com-wiki-card .info-section .info-line .enter-wiki, .com-wiki-card .info-section .info-line i ) { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .com-game-card .game-info .line-2 .price-box .price-content-box .original-price::after { background-color: var(--heybox-up-muted) !important; } /* 药丸/标签底:写死 #f3f4f5,配深色文字,深色下变成亮块加暗字。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is( .com-game-card .game-info .line-2 .price-box, .com-game-card .game-info .line-2 .follow-box, .com-album-card .delete-album, .com-album-card .album-info .line-2 .favour-btn.favoured, .com-award-table-card .award-table-row.title, .com-award-table-card .award-table-row.unfold, .com-league-card .league-info-box .line-2 .league-date, .com-league-card .league-info-box .line-2 .price-pool, .com-match-card .match-info-wrapper .line-2 .aftermatch .team-score, .com-match-card .match-info-wrapper .line-2 .watched, .com-match-card .match-info-wrapper .line-2 .commingsoon, .com-match-card .match-info-wrapper .line-2 .aftermatch .colon, .com-sku-card .sku-info-box .line-2 .price-box ) { background-color: var(--heybox-up-raised) !important; } /* 深色实心按钮:写死 #14191e 或同色渐变,深色下与卡片底几乎融为一体。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is( .com-game-card .game-info .line-2 .follow-btn, .com-game-card .game-info .line-2 .wishlist-box, .com-game-card .game-info .line-2 .follow-box i, .com-game-card .game-info .line-2 .follow-box .follow-unit, .com-game-card .game-info .line-2 .wishlist-box i, .com-game-card .game-info .line-2 .wishlist-box .add-wishlist-text, .com-album-card .album-info .line-1 .album-label, .com-album-card .album-info .line-2 .favour-btn, .com-match-card .match-info-wrapper .line-2 .unwatched, .com-mod-card .mod-card-download, .com-mod-card .mod-card-download .mod-card-download-text, .com-sku-card .sku-info-box .line-2 .follow-btn ) { background-color: var(--heybox-up-raised) !important; background-image: none !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .com-album-card .album-info .line-2 .favour-btn.favoured { color: var(--heybox-up-text-2) !important; } /* 图片占位底与未加载图片的灰块。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] :is(.com-album-card .game-img-album, .com-mod-card .mod-card-image-box) { background-color: var(--heybox-up-raised) !important; } /* 评论区与列表底部的写死浅色小件。 */ :root.heybox-up-theme[data-heybox-up-dark="true"] .comment-item__writer-like span { background-color: var(--heybox-up-raised) !important; color: var(--heybox-up-text-2) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .children-item__comment-creator { color: var(--heybox-up-accent) !important; } :root.heybox-up-theme[data-heybox-up-dark="true"] .scroll-list__no-more-desc::before { background-color: var(--heybox-up-border) !important; } /* 文章管理页:列表末尾的“暂无更多内容”会打断卡片流,直接隐藏。 */ #page-creator-content-management .scroll-list__no-more-desc { display: none !important; } /* 收藏与文章页:右栏只剩二维码、推广与备案信息,移除后让主列表占满内容区。 */ :is(#page-user-favour, #page-creator-content-management, #page-creator-content-management-detail) .cpt-right-side.right { display: none !important; } :is(#page-user-favour, #page-creator-content-management, #page-creator-content-management-detail) .content > .list { flex: 1 1 auto !important; width: auto !important; max-width: none !important; } /* Global search page: right rail holds only hot searches, a QR code, app links and ICP text. */ #page-bbs-list .cpt-right-side.right { display: none !important; } #page-bbs-list .content > .list { flex: 1 1 0 !important; width: auto !important; min-width: 0 !important; max-width: none !important; } /* The native control anchors to the content box, so it drifts under rails and off the viewport. */ .scroll-list__button-group { right: 20px !important; bottom: 24px !important; left: auto !important; } #page-bbs-community .scroll-list__button-group, #page-topic-link .scroll-list__button-group, #page-bbs-link .scroll-list__button-group { display: none !important; } /* Site confirm dialogs mount on body with fixed light surfaces behind the reader layer. */ :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert { background-color: var(--heybox-up-surface) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert-title { color: var(--heybox-up-text) !important; } :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert-desc { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert-input-wrapper { background-color: var(--heybox-up-subtle) !important; box-shadow: inset 0 0 0 1px var(--heybox-up-border) !important; } :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert-input { color: var(--heybox-up-text) !important; } :root.heybox-up-theme .alert-container-wrapper .hb-cpt__alert-input::placeholder { color: var(--heybox-up-muted) !important; } :root.heybox-up-theme .alert-container-wrapper .alert-btn-box__btn.cancel { background-color: var(--heybox-up-subtle) !important; color: var(--heybox-up-text) !important; } :root.heybox-up-theme .alert-container-wrapper .alert-btn-box__btn.confirm { background: var(--heybox-up-accent) !important; color: var(--heybox-up-on-accent) !important; } `; var THEME_ENTRY_CSS = ` :host { display: inline-flex; flex: none; position: relative; gap: 2px; } .button { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--heybox-up-icon, #62676e); cursor: pointer; } .button:hover { background: var(--heybox-up-icon-hover-bg, rgba(20, 25, 30, .06)); color: var(--heybox-up-icon-hover, #14191e); } .button:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: 2px; } svg { display: block; width: 18px; height: 18px; } .menu { position: absolute; top: 38px; right: 0; z-index: 1000; width: 236px; padding: 6px; border: 1px solid var(--hbu-menu-border, rgba(20, 25, 30, .12)); border-radius: 12px; background: var(--hbu-menu-bg, #fff); color: var(--hbu-menu-text, #14191e); box-shadow: 0 14px 34px rgba(0, 0, 0, .18); } .theme-menu { right: 34px; } .settings-menu { max-height: min(70vh, 560px); overflow-y: auto; overscroll-behavior: contain; } .menu[hidden] { display: none; } .menu-title { padding: 6px 10px 4px; font-size: 11px; letter-spacing: .08em; color: var(--hbu-menu-muted, #6b7280); } .item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 8px; border: 0; border-radius: 8px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } .item:hover, .item:focus-visible { background: var(--hbu-menu-hover, rgba(20, 25, 30, .06)); outline: none; } .item[aria-checked="true"] { background: var(--hbu-menu-hover, rgba(20, 25, 30, .06)); } .swatch { flex: none; width: 28px; height: 28px; border-radius: 7px; border: 1px solid var(--hbu-menu-border, rgba(20, 25, 30, .12)); background: linear-gradient(135deg, var(--a) 0 55%, var(--b) 55%); } .copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .name { font-size: 13px; line-height: 1.3; } .hint { font-size: 11px; line-height: 1.3; color: var(--hbu-menu-muted, #6b7280); } .check { margin-left: auto; font-size: 12px; color: var(--heybox-up-accent, #6f8db3); opacity: 0; } .item[aria-checked="true"] .check { opacity: 1; } .sep { height: 1px; margin: 6px 4px; background: var(--hbu-menu-border, rgba(20, 25, 30, .12)); } .field { padding: 2px 8px 6px; } .field + .field { padding-top: 0; } .field-label { padding: 0 2px 6px; font-size: 11px; letter-spacing: .08em; color: var(--hbu-menu-muted, #6b7280); } .field-row { display: flex; gap: 6px; align-items: center; } .number { width: 76px; height: 28px; padding: 0 8px; border: 1px solid var(--hbu-menu-border, rgba(20, 25, 30, .12)); border-radius: 7px; outline: none; background: transparent; color: inherit; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; } .number:focus { border-color: var(--heybox-up-accent, #6f8db3); } .unit { font-size: 11px; color: var(--hbu-menu-muted, #6b7280); } .chips { display: flex; gap: 4px; } .chip { flex: 1 1 0; min-width: 0; padding: 5px 0; border: 1px solid var(--hbu-menu-border, rgba(20, 25, 30, .12)); border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; text-align: center; cursor: pointer; transition: color .14s ease, border-color .14s ease, background .14s ease; } .chip:hover { background: var(--hbu-menu-hover, rgba(20, 25, 30, .06)); } .chip:focus-visible { outline: 2px solid var(--heybox-up-accent, #6f8db3); outline-offset: 1px; } .chip[aria-checked="true"] { border-color: var(--heybox-up-accent, #6f8db3); color: var(--heybox-up-accent, #6f8db3); background: var(--hbu-menu-hover, rgba(20, 25, 30, .06)); } .item.toggle[aria-checked="true"] { background: transparent; } .switch { position: relative; flex: none; margin-left: auto; width: 30px; height: 16px; border-radius: 999px; border: 1px solid var(--hbu-menu-border, rgba(20, 25, 30, .12)); background: var(--hbu-menu-hover, rgba(20, 25, 30, .06)); transition: border-color .15s ease; } .switch::after { content: ""; position: absolute; top: 1px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--hbu-menu-muted, #6b7280); transition: transform .15s ease, background .15s ease; } .item[aria-checked="true"] .switch { border-color: var(--heybox-up-accent, #6f8db3); } .item[aria-checked="true"] .switch::after { transform: translateX(13px); background: var(--heybox-up-accent, #6f8db3); } `; var THEME_ICONS = { system: '', dark: '', oled: '', light: '', paper: '', mist: '' }; var SETTINGS_ICON = ''; function ensureThemeStyle() { ensureStyleElement(THEME_STYLE_ID, THEME_CSS, true); } function updateThemeEntry() { if (!themeEntry) return; const preset = getThemePreset(currentThemeMode); themeEntry.button.innerHTML = THEME_ICONS[currentThemeMode]; themeEntry.button.title = "主题:" + preset.label + "(点击选择)"; themeEntry.button.setAttribute("aria-label", "主题:" + preset.label + ",点击选择"); themeEntry.button.setAttribute("aria-expanded", String(!themeEntry.menu.hidden)); themeEntry.settingsButton.setAttribute("aria-expanded", String(!themeEntry.settingsMenu.hidden)); for (const item of themeEntry.items) { item.node.setAttribute("aria-checked", String(item.id === currentThemeMode)); } if (previewToggle) previewToggle.setAttribute("aria-checked", String(readCommentPreviewPreference())); if (navMotionToggle) navMotionToggle.setAttribute("aria-checked", String(readNavMotionPreference())); if (readerToggle) readerToggle.setAttribute("aria-checked", String(readReaderPreference())); syncFeedWidthControls(readFeedWidth()); syncNumberInput(readerLineHeightInput, readReaderLineHeight()); } function applyTheme(mode, persist) { if (isOfficialHomePath(location.pathname)) { releaseTheme(); return; } currentThemeMode = normalizeThemeMode(mode); const effective = resolveTheme(currentThemeMode, THEME_MEDIA.matches); const preset = getThemePreset(effective); const root = document.documentElement; root.classList.add(THEME_CLASS); root.setAttribute(THEME_ATTR, effective); root.setAttribute("data-heybox-up-dark", String(preset.dark === true)); root.style.colorScheme = preset.dark === true ? "dark" : "light"; ensureThemeStyle(); if (persist) writeThemeMode(currentThemeMode); updateThemeEntry(); if (preset.dark === true) scheduleDarkSweep(true); else clearDarkSweep(); } function releaseTheme() { clearDarkSweep(); const root = document.documentElement; root.classList.remove(THEME_CLASS); root.removeAttribute(THEME_ATTR); root.removeAttribute("data-heybox-up-dark"); root.style.removeProperty("color-scheme"); themeEntry?.host.remove(); themeEntry = null; removeStyleElement(THEME_STYLE_ID); } function createThemeEntry() { const host = document.createElement("span"); host.id = THEME_ENTRY_ID; host.className = "heybox-up-theme-entry"; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = THEME_ENTRY_CSS; const button = document.createElement("button"); button.type = "button"; button.className = "button"; button.setAttribute("aria-haspopup", "menu"); button.setAttribute("aria-expanded", "false"); const settingsButton = document.createElement("button"); settingsButton.type = "button"; settingsButton.className = "button"; settingsButton.setAttribute("aria-haspopup", "menu"); settingsButton.setAttribute("aria-expanded", "false"); settingsButton.setAttribute("aria-label", "打开设置"); settingsButton.title = "设置"; settingsButton.innerHTML = SETTINGS_ICON; const menu = document.createElement("div"); menu.className = "menu theme-menu"; menu.hidden = true; menu.setAttribute("role", "menu"); menu.setAttribute("aria-label", "主题预设"); menu.append(createThemeMenuTitle()); const settingsMenu = document.createElement("div"); settingsMenu.className = "menu settings-menu"; settingsMenu.hidden = true; settingsMenu.setAttribute("role", "menu"); settingsMenu.setAttribute("aria-label", "设置"); settingsMenu.append(createSettingsMenuTitle()); const items = THEME_PRESETS.map((preset) => { const node = document.createElement("button"); node.type = "button"; node.className = "item"; node.dataset["theme"] = preset.id; node.setAttribute("role", "menuitemradio"); node.setAttribute("aria-checked", "false"); const swatch = document.createElement("span"); swatch.className = "swatch"; swatch.style.setProperty("--a", preset.swatch[0]); swatch.style.setProperty("--b", preset.swatch[1]); const copy = document.createElement("span"); copy.className = "copy"; const name = document.createElement("span"); name.className = "name"; name.textContent = preset.label; const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = preset.hint; copy.append(name, hint); const check = document.createElement("span"); check.className = "check"; check.textContent = "✓"; node.append(swatch, copy, check); node.addEventListener("click", () => { applyTheme(preset.id, true); closeThemeMenu(); button.focus(); }); return { id: preset.id, node }; }); menu.append(...items.map((item) => item.node)); settingsMenu.append( createCommentPreviewToggle(), createNavMotionToggle(), createReaderToggle(), createReaderLineHeightControl(), createThemeMenuSeparator(), createFeedWidthControl() ); const closeThemeMenu = () => { if (menu.hidden) return; menu.hidden = true; button.setAttribute("aria-expanded", "false"); updateThemeEntry(); }; const closeSettingsMenu = () => { if (settingsMenu.hidden) return; settingsMenu.hidden = true; settingsButton.setAttribute("aria-expanded", "false"); updateThemeEntry(); }; button.addEventListener("click", () => { closeSettingsMenu(); menu.hidden = !menu.hidden; button.setAttribute("aria-expanded", String(!menu.hidden)); updateThemeEntry(); }); settingsButton.addEventListener("click", () => { closeThemeMenu(); settingsMenu.hidden = !settingsMenu.hidden; settingsButton.setAttribute("aria-expanded", String(!settingsMenu.hidden)); updateThemeEntry(); }); document.addEventListener("click", (event) => { if (menu.hidden && settingsMenu.hidden || event.composedPath().includes(host)) return; closeThemeMenu(); closeSettingsMenu(); }, true); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || menu.hidden && settingsMenu.hidden) return; event.preventDefault(); const returnTarget = settingsMenu.hidden ? button : settingsButton; closeThemeMenu(); closeSettingsMenu(); returnTarget.focus(); }); shadow.append(style, button, settingsButton, menu, settingsMenu); themeEntry = { host, button, menu, items, settingsButton, settingsMenu }; updateThemeEntry(); } function createThemeMenuTitle() { const title = document.createElement("div"); title.className = "menu-title"; title.textContent = "主题预设"; return title; } function createSettingsMenuTitle() { const title = document.createElement("div"); title.className = "menu-title"; title.textContent = "设置"; return title; } function createThemeMenuSeparator() { const line = document.createElement("div"); line.className = "sep"; line.setAttribute("role", "separator"); return line; } function createCommentPreviewToggle() { const node = document.createElement("button"); node.type = "button"; node.className = "item toggle"; node.setAttribute("role", "menuitemcheckbox"); node.setAttribute("aria-checked", String(readCommentPreviewPreference())); const copy = document.createElement("span"); copy.className = "copy"; const name = document.createElement("span"); name.className = "name"; name.textContent = "首页评论预览"; const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = "信息流右侧直接看评论"; copy.append(name, hint); const toggle = document.createElement("span"); toggle.className = "switch"; node.append(copy, toggle); node.addEventListener("click", () => { const next = !(node.getAttribute("aria-checked") === "true"); node.setAttribute("aria-checked", String(next)); setCommentPreviewPreference(next); }); previewToggle = node; return node; } function createNavMotionToggle() { const node = document.createElement("button"); node.type = "button"; node.className = "item toggle"; node.setAttribute("role", "menuitemcheckbox"); node.setAttribute("aria-checked", String(readNavMotionPreference())); const copy = document.createElement("span"); copy.className = "copy"; const name = document.createElement("span"); name.className = "name"; name.textContent = "动态导航图标"; const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = "图标持续描边流转,悬停时加快"; copy.append(name, hint); const toggle = document.createElement("span"); toggle.className = "switch"; node.append(copy, toggle); node.addEventListener("click", () => { const next = !(node.getAttribute("aria-checked") === "true"); node.setAttribute("aria-checked", String(next)); setNavMotionPreference(next); }); navMotionToggle = node; return node; } function createReaderToggle() { const node = document.createElement("button"); node.type = "button"; node.className = "item toggle"; node.setAttribute("role", "menuitemcheckbox"); node.setAttribute("aria-checked", String(readReaderPreference())); const copy = document.createElement("span"); copy.className = "copy"; const name = document.createElement("span"); name.className = "name"; name.textContent = "沉浸阅读"; const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = "帖子页全屏沉浸阅读,评论收进侧栏"; copy.append(name, hint); const toggle = document.createElement("span"); toggle.className = "switch"; node.append(copy, toggle); node.addEventListener("click", () => { const next = !(node.getAttribute("aria-checked") === "true"); node.setAttribute("aria-checked", String(next)); setReaderPreference(next); }); readerToggle = node; return node; } var FEED_WIDTH_STEPS = [40, 48, 56, 64, 72]; function inputRootActiveElement(input) { const root = input.getRootNode(); return root instanceof ShadowRoot ? root.activeElement : document.activeElement; } function syncNumberInput(input, value) { if (input === null) return; if (inputRootActiveElement(input) === input) return; input.value = String(value); } function bindNumberInput(input, commit) { input.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); commit(); input.blur(); }); input.addEventListener("change", commit); input.addEventListener("blur", commit); } function createReaderLineHeightControl() { const wrap = document.createElement("div"); wrap.className = "field"; wrap.setAttribute("role", "group"); wrap.setAttribute("aria-label", "正文行距"); const label = document.createElement("div"); label.className = "field-label"; label.textContent = "正文行距"; const row = document.createElement("div"); row.className = "field-row"; const input = document.createElement("input"); input.type = "number"; input.className = "number"; input.min = String(READER_LINE_HEIGHT_MIN); input.max = String(READER_LINE_HEIGHT_MAX); input.step = String(READER_LINE_HEIGHT_STEP); input.inputMode = "decimal"; input.value = String(readReaderLineHeight()); input.setAttribute("aria-label", "正文行距倍数"); input.title = "正文行距 " + READER_LINE_HEIGHT_MIN + " - " + READER_LINE_HEIGHT_MAX + " 倍"; const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = "倍"; row.append(input, unit); wrap.append(label, row); bindNumberInput(input, () => { const next = parseReaderLineHeightInput(input.value); const value = next === null ? readReaderLineHeight() : setReaderLineHeightPreference(next); input.value = String(value); updateThemeEntry(); }); readerLineHeightInput = input; return wrap; } function syncFeedWidthChips(value) { for (const chip of feedWidthChips) { if (!(chip instanceof HTMLElement)) continue; chip.setAttribute("aria-checked", String(Number(chip.dataset["width"]) === value)); } } function syncFeedWidthControls(value) { syncFeedWidthChips(value); syncNumberInput(feedWidthInput, value); } function createFeedWidthControl() { const wrap = document.createElement("div"); wrap.className = "field"; wrap.setAttribute("role", "group"); wrap.setAttribute("aria-label", "中栏宽度"); const label = document.createElement("div"); label.className = "field-label"; label.textContent = "中栏宽度(占屏幕宽度)"; const chips = document.createElement("div"); chips.className = "chips"; for (const step of FEED_WIDTH_STEPS) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "chip"; chip.dataset["width"] = String(step); chip.textContent = step + "%"; chip.title = "中栏占屏幕宽度 " + step + "%"; chip.setAttribute("role", "menuitemradio"); chip.setAttribute("aria-checked", "false"); chip.addEventListener("click", () => { const width = setFeedWidthPreference(step); syncFeedWidthControls(width); }); chips.append(chip); } const row = document.createElement("div"); row.className = "field-row"; const input = document.createElement("input"); input.type = "number"; input.className = "number"; input.min = String(FEED_WIDTH_MIN); input.max = String(FEED_WIDTH_MAX); input.step = String(FEED_WIDTH_STEP); input.inputMode = "numeric"; input.value = String(readFeedWidth()); input.setAttribute("aria-label", "自定义中栏宽度百分比"); input.title = "自定义中栏宽度 " + FEED_WIDTH_MIN + " - " + FEED_WIDTH_MAX + "%"; const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = "%"; row.append(input, unit); bindNumberInput(input, () => { const next = parseFeedWidthInput(input.value); const value = next === null ? readFeedWidth() : setFeedWidthPreference(next); input.value = String(value); syncFeedWidthChips(value); updateThemeEntry(); }); feedWidthInput = input; wrap.append(label, chips, row); feedWidthChips = Array.from(chips.children); syncFeedWidthControls(readFeedWidth()); return wrap; } function mountThemeEntry() { if (!document.body) return; if (themeEntry?.host.isConnected && themeEntry.host.parentElement?.classList.contains("nav-actions")) return; const actions = document.querySelector(".nav-actions"); if (!actions) return; if (!themeEntry) createThemeEntry(); const entry = themeEntry; if (entry === null) return; if (entry.host.parentNode !== actions) { const anchor = actions.querySelector(".publish-btn, .login-btn"); actions.insertBefore(entry.host, anchor); } } function watchTheme() { if (!document.body) return; const sync = () => { if (isOfficialHomePath(location.pathname)) { releaseTheme(); return; } if (document.documentElement.getAttribute(THEME_ATTR) === null) applyTheme(readThemeMode(), false); ensureThemeStyle(); mountThemeEntry(); scheduleDarkSweep(); }; registerPageSync(sync, 400, ADAPTED_ROUTES, "chrome"); registerPageSync(() => scheduleDarkSweep(), 600, [PAGE_ROUTE.CREATOR, PAGE_ROUTE.TOPIC]); sync(); } var DARK_SWEEP_ROOT_SELECTOR = "#page-creator-content-management, #page-creator-content-management-detail, #page-topic-link"; var DARK_FLAT_CLASS = "heybox-up-dark-flat"; var DARK_FLAT_SUBTLE_CLASS = "heybox-up-dark-flat-subtle"; var DARK_EDGE_CLASS = "heybox-up-dark-edge"; var DARK_INK_CLASS = "heybox-up-dark-ink"; var DARK_SWEEP_LIMIT = 3e3; var DARK_SWEEP_SKIP = /* @__PURE__ */ new Set([ "IMG", "VIDEO", "CANVAS", "SVG", "PATH", "USE", "IFRAME", "INPUT", "TEXTAREA", "SELECT", "OPTION", "SCRIPT", "STYLE", "LINK" ]); var RGB_COLOR = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/; var darkSweepTimer = 0; var darkSweepIdleHandle = 0; var darkSweepLastRun = 0; function parseRgbColor(value) { const match = RGB_COLOR.exec(value || ""); if (match === null) return null; return { r: Number(match[1] ?? 0), g: Number(match[2] ?? 0), b: Number(match[3] ?? 0), a: match[4] === void 0 ? 1 : Number(match[4]) }; } function isNearWhite(color, min) { return color !== null && color.a >= 0.6 && (color.r + color.g + color.b) / 3 >= min; } function hasOwnText(node) { for (const child of node.childNodes) { const text = child.nodeValue; if (child.nodeType === 3 && text !== null && text.trim() !== "") return true; } return false; } function clearDarkSweep() { if (darkSweepTimer !== 0) { window.clearTimeout(darkSweepTimer); darkSweepTimer = 0; } if (darkSweepIdleHandle !== 0 && typeof window.cancelIdleCallback === "function") { window.cancelIdleCallback(darkSweepIdleHandle); darkSweepIdleHandle = 0; } const selector = "." + DARK_FLAT_CLASS + ", ." + DARK_FLAT_SUBTLE_CLASS + ", ." + DARK_EDGE_CLASS + ", ." + DARK_INK_CLASS; for (const node of document.querySelectorAll(selector)) { node.classList.remove(DARK_FLAT_CLASS, DARK_FLAT_SUBTLE_CLASS, DARK_EDGE_CLASS, DARK_INK_CLASS); } } function sweepDarkSurfaces() { if (document.documentElement.getAttribute("data-heybox-up-dark") !== "true") { clearDarkSweep(); return; } const roots = document.querySelectorAll(DARK_SWEEP_ROOT_SELECTOR); if (roots.length === 0) return; const styleCache = /* @__PURE__ */ new WeakMap(); const flatCache = /* @__PURE__ */ new WeakMap(); const readStyle = (node) => { let style = styleCache.get(node); if (style === void 0) { style = getComputedStyle(node); styleCache.set(node, style); } return style; }; const hasFlatBackground = (node) => { const path = []; let element = node; while (element !== null) { if (element.classList.contains(DARK_FLAT_CLASS) || element.classList.contains(DARK_FLAT_SUBTLE_CLASS)) { for (const item of path) flatCache.set(item, true); return true; } const known = flatCache.get(element); if (known !== void 0) { for (const item of path) flatCache.set(item, known); return known; } path.push(element); const color = parseRgbColor(readStyle(element).backgroundColor); if (color !== null && color.a >= 0.6) { for (const item of path) flatCache.set(item, false); return false; } element = element.parentElement; } for (const item of path) flatCache.set(item, false); return false; }; for (const root of roots) { const nodes = root.querySelectorAll("*"); const total = Math.min(nodes.length, DARK_SWEEP_LIMIT); for (let index = 0; index < total; index += 1) { const node = nodes[index]; if (!(node instanceof HTMLElement)) continue; if (DARK_SWEEP_SKIP.has(node.tagName) || node.id.startsWith("heybox-up")) continue; const style = readStyle(node); if (!node.classList.contains(DARK_FLAT_CLASS) && !node.classList.contains(DARK_FLAT_SUBTLE_CLASS)) { if (style.backgroundImage === "none") { const bg = parseRgbColor(style.backgroundColor); if (isNearWhite(bg, 240)) { node.classList.add(isNearWhite(bg, 250) ? DARK_FLAT_CLASS : DARK_FLAT_SUBTLE_CLASS); flatCache.set(node, true); } } } if (!node.classList.contains(DARK_EDGE_CLASS) && styleHasBrightEdge(style)) { node.classList.add(DARK_EDGE_CLASS); } if (!node.classList.contains(DARK_INK_CLASS) && hasOwnText(node)) { const ink = parseRgbColor(style.color); if (ink !== null && (ink.r + ink.g + ink.b) / 3 < 120 && hasFlatBackground(node)) { node.classList.add(DARK_INK_CLASS); } } } } } function styleHasBrightEdge(style) { if (style.borderStyle === "none" || style.borderStyle === "") return false; if (style.borderTopWidth === "0px" && style.borderRightWidth === "0px" && style.borderBottomWidth === "0px" && style.borderLeftWidth === "0px") return false; return isNearWhite(parseRgbColor(style.borderTopColor), 240) || isNearWhite(parseRgbColor(style.borderRightColor), 240) || isNearWhite(parseRgbColor(style.borderBottomColor), 240) || isNearWhite(parseRgbColor(style.borderLeftColor), 240); } function scheduleDarkSweep(force = false) { if (document.documentElement.getAttribute("data-heybox-up-dark") !== "true") return; if (document.querySelector(DARK_SWEEP_ROOT_SELECTOR) === null) return; if (darkSweepTimer !== 0 || darkSweepIdleHandle !== 0) { if (!force) return; window.clearTimeout(darkSweepTimer); darkSweepTimer = 0; if (darkSweepIdleHandle !== 0 && typeof window.cancelIdleCallback === "function") { window.cancelIdleCallback(darkSweepIdleHandle); darkSweepIdleHandle = 0; } } const elapsed = performance.now() - darkSweepLastRun; const delay = force ? 350 : Math.max(350, 1200 - elapsed); darkSweepTimer = window.setTimeout(() => { darkSweepTimer = 0; darkSweepLastRun = performance.now(); if (typeof window.requestIdleCallback === "function") { darkSweepIdleHandle = window.requestIdleCallback(() => { darkSweepIdleHandle = 0; sweepDarkSurfaces(); }, { timeout: 1e3 }); } else { sweepDarkSurfaces(); } }, delay); } // scripts/heybox-up/src/ui/reader.ts var READER_ID = "heybox-up-reader"; var READER_STYLE_ID = "heybox-up-reader-style"; var READER_CLASS = "heybox-up-reader"; var READER_SOURCE_ROOT_SELECTOR = ":is(.hb-bbs-image-text, .hb-bbs-post, .hb-bbs__video)"; var READER_SOURCE_BODY_SELECTOR = ".image-text__content, .post__content, .bbs-video__content-wrapper"; var READER_SOURCE_TITLE_SELECTOR = ".section-title__content, .post__title, .image-text__title"; var READER_SOURCE_HEADER_SELECTOR = ".image-text__header-image img, .post__header-image img"; var READER_SOURCE_VIDEO_SELECTOR = ".bbs-video__video-container video, video.bbs-video__video-box, .hb-bbs__video video"; var READER_MEDIA_EXCLUDE_SELECTOR = ".comment, .link-comment, .com-award-table, .com-game-card, .bbs-link__related-recommend, .bbs-video__user, .bbs-video__tags, .link-user, [class*='avatar'], [class*='icon'], [class*='emoji']"; var READER_SCROLL_KEY = "heybox-up.readerScroll.v1:"; var READER_RATIO_KEY = "heybox-up.readerRatio.v1:"; var READER_PENDING_NOTICE_MS = 9e3; var COMMENT_VERIFY_DELAYS_MS = [400, 600, 900, 1200, 1500]; var COMMENT_VERIFY_ATTEMPTS = COMMENT_VERIFY_DELAYS_MS.length; var COMMENT_VERIFY_GRACE_DELAYS_MS = [6e3, 12e3, 2e4]; var COMMENT_VERIFY_WAIT_COUNT = COMMENT_VERIFY_DELAYS_MS.length + COMMENT_VERIFY_GRACE_DELAYS_MS.length; var COMMENT_VERIFY_MAX_ATTEMPTS = COMMENT_VERIFY_WAIT_COUNT + 1; var readCommentVerifyDelay = (attempt) => { const index = Math.max(0, attempt - 1); if (index < COMMENT_VERIFY_DELAYS_MS.length) return COMMENT_VERIFY_DELAYS_MS[index]; const graceIndex = Math.min(index - COMMENT_VERIFY_DELAYS_MS.length, COMMENT_VERIFY_GRACE_DELAYS_MS.length - 1); return COMMENT_VERIFY_GRACE_DELAYS_MS[graceIndex]; }; var RAIL_MAX_INDENT_DEPTH = 4; var READER_ICONS = { back: '', home: '', close: '', like: '', likeOn: '', star: '', starOn: '', comment: '', share: '', previous: '', next: '', refresh: '' }; var READER_CSS = ` :root { --heybox-up-reading-line-height: 1.7; --hbu-reader-stage: 732px; /* Light preset. It is also the fallback set every other preset overrides. */ --hbu-reader-bg: #e9eef3; --hbu-reader-page: #fff; --hbu-reader-subtle: #f1f4f7; --hbu-reader-ink: #14191e; --hbu-reader-ink-2: #3c444c; --hbu-reader-muted: #5f6b77; --hbu-reader-border: rgba(20, 25, 30, .12); --hbu-reader-glow: rgba(111, 141, 179, .16); --hbu-reader-accent: #6f8db3; --hbu-reader-on-accent: #10161c; --hbu-reader-link: #4a678d; --hbu-reader-glass: rgba(255, 255, 255, .88); --hbu-reader-glass-ink: #14191e; --hbu-reader-glass-border: rgba(20, 25, 30, .12); --hbu-reader-head: linear-gradient(180deg, rgba(255, 255, 255, .94), rgba(255, 255, 255, .6) 58%, transparent); --hbu-reader-float: rgba(255, 255, 255, .98); --hbu-reader-float-ink: #14191e; --hbu-reader-float-border: rgba(20, 25, 30, .14); --hbu-reader-float-hover: rgba(20, 25, 30, .06); --hbu-reader-float-muted: #5a6572; --hbu-reader-active-like: #c0364e; --hbu-reader-active-favour: #8a6512; --hbu-reader-active-cy: #2f7a52; --hbu-reader-danger: #c0364e; --hbu-reader-scrim: rgba(20, 25, 30, .42); --hbu-reader-shadow: 0 20px 60px rgba(16, 24, 40, .18); } :root.heybox-up-theme[data-heybox-up-dark="true"] { --hbu-reader-bg: #080b0f; --hbu-reader-page: #12181f; --hbu-reader-subtle: #1b232c; --hbu-reader-ink: #e6edf5; --hbu-reader-ink-2: #b7c2ce; --hbu-reader-muted: #8b99a8; --hbu-reader-border: #2b3642; --hbu-reader-glow: rgba(143, 176, 207, .2); --hbu-reader-accent: #8fb0cf; --hbu-reader-on-accent: #10161c; --hbu-reader-link: #8fb0cf; --hbu-reader-glass: rgba(21, 28, 36, .84); --hbu-reader-glass-ink: #e6edf5; --hbu-reader-glass-border: rgba(230, 237, 245, .14); --hbu-reader-head: linear-gradient(180deg, rgba(18, 24, 31, .94), rgba(18, 24, 31, .6) 58%, transparent); --hbu-reader-float: rgba(21, 28, 36, .97); --hbu-reader-float-ink: #e6edf5; --hbu-reader-float-border: #2b3642; --hbu-reader-float-hover: rgba(230, 237, 245, .1); --hbu-reader-float-muted: #9dabb9; --hbu-reader-active-like: #ff8e9a; --hbu-reader-active-favour: #f0c56a; --hbu-reader-active-cy: #8fd0a8; --hbu-reader-danger: #ff8e9a; --hbu-reader-scrim: rgba(2, 4, 7, .62); --hbu-reader-shadow: 0 24px 70px rgba(0, 0, 0, .5); } :root.heybox-up-theme[data-heybox-up-theme="oled"] { --hbu-reader-bg: #000; --hbu-reader-page: #090909; --hbu-reader-subtle: #141414; --hbu-reader-border: #242424; --hbu-reader-glow: rgba(168, 192, 216, .12); --hbu-reader-accent: #a8c0d8; --hbu-reader-on-accent: #05070a; --hbu-reader-link: #a8c0d8; --hbu-reader-glass: rgba(16, 16, 16, .86); --hbu-reader-glass-border: rgba(230, 237, 245, .14); --hbu-reader-head: linear-gradient(180deg, rgba(9, 9, 9, .94), rgba(9, 9, 9, .6) 58%, transparent); --hbu-reader-float: rgba(12, 12, 12, .97); --hbu-reader-float-ink: #e6edf5; --hbu-reader-float-border: #242424; --hbu-reader-float-hover: rgba(230, 237, 245, .1); --hbu-reader-float-muted: #9dabb9; --hbu-reader-active-like: #ff8e9a; --hbu-reader-active-favour: #f0c56a; --hbu-reader-active-cy: #8fd0a8; --hbu-reader-danger: #ff8e9a; --hbu-reader-scrim: rgba(0, 0, 0, .74); --hbu-reader-shadow: 0 24px 70px rgba(0, 0, 0, .72); } :root.heybox-up-theme[data-heybox-up-theme="paper"] { --hbu-reader-bg: #e7dfd0; --hbu-reader-page: #faf5ea; --hbu-reader-subtle: #f2eadb; --hbu-reader-ink: #332b21; --hbu-reader-ink-2: #5b5042; --hbu-reader-muted: #726451; --hbu-reader-border: #ddd0bc; --hbu-reader-glow: rgba(138, 111, 78, .14); --hbu-reader-accent: #7b6244; --hbu-reader-on-accent: #fffdf7; --hbu-reader-link: #7b6244; --hbu-reader-glass: rgba(250, 245, 234, .9); --hbu-reader-glass-ink: #332b21; --hbu-reader-glass-border: rgba(51, 43, 33, .16); --hbu-reader-head: linear-gradient(180deg, rgba(250, 245, 234, .94), rgba(250, 245, 234, .6) 58%, transparent); --hbu-reader-float: rgba(255, 253, 247, .98); --hbu-reader-float-ink: #332b21; --hbu-reader-float-border: #ddd0bc; --hbu-reader-float-hover: rgba(51, 43, 33, .07); --hbu-reader-float-muted: #6a5d4b; --hbu-reader-active-like: #b23a4c; --hbu-reader-active-favour: #7d5a16; --hbu-reader-active-cy: #2e6d4c; --hbu-reader-danger: #b23a4c; --hbu-reader-scrim: rgba(51, 43, 33, .46); --hbu-reader-shadow: 0 18px 44px rgba(51, 43, 33, .18); } :root.heybox-up-theme[data-heybox-up-theme="mist"] { --hbu-reader-bg: #dde8ea; --hbu-reader-page: #f7fbfb; --hbu-reader-subtle: #eaf2f3; --hbu-reader-ink: #1d3338; --hbu-reader-ink-2: #405a60; --hbu-reader-muted: #567076; --hbu-reader-border: #cbdcdf; --hbu-reader-glow: rgba(79, 127, 138, .16); --hbu-reader-accent: #40646d; --hbu-reader-on-accent: #fff; --hbu-reader-link: #40646d; --hbu-reader-glass: rgba(251, 253, 253, .9); --hbu-reader-glass-ink: #1d3338; --hbu-reader-glass-border: rgba(29, 51, 56, .16); --hbu-reader-head: linear-gradient(180deg, rgba(247, 251, 251, .94), rgba(247, 251, 251, .6) 58%, transparent); --hbu-reader-float: rgba(252, 254, 254, .98); --hbu-reader-float-ink: #1d3338; --hbu-reader-float-border: #cbdcdf; --hbu-reader-float-hover: rgba(29, 51, 56, .07); --hbu-reader-float-muted: #4f686e; --hbu-reader-active-like: #b83c50; --hbu-reader-active-favour: #83601a; --hbu-reader-active-cy: #2b6f55; --hbu-reader-danger: #b83c50; --hbu-reader-scrim: rgba(29, 51, 56, .44); --hbu-reader-shadow: 0 18px 44px rgba(47, 74, 84, .2); } body.heybox-up-reader { overflow: hidden !important; background: var(--hbu-reader-bg) !important; } body.heybox-up-reader .nav, body.heybox-up-reader .hb-bbs-link__header { display: none !important; } /* The custom panel is the only comment surface in the reader; the native drawer and its reply box stay in the DOM but never paint. */ body.heybox-up-reader #page-bbs-link .link-comment, body.heybox-up-reader .hb-bbs-link__reply-target { display: none !important; } body.heybox-up-reader #page-bbs-link { position: fixed !important; inset: 0 !important; z-index: 4000; display: flex !important; justify-content: center; width: 100% !important; height: 100% !important; background: radial-gradient(70% 60% at 50% 0%, var(--hbu-reader-glow), transparent 70%), var(--hbu-reader-bg) !important; } body.heybox-up-reader #page-bbs-link .content, body.heybox-up-reader #page-bbs-link .list, body.heybox-up-reader #page-bbs-link .hb-bbs-link, body.heybox-up-reader #page-bbs-link .hb-bbs-link__container, body.heybox-up-reader #page-bbs-link .hb-bbs-link__content { display: block !important; width: 100% !important; max-width: none !important; height: 100% !important; min-width: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; } body.heybox-up-reader .hb-bbs-image-text, body.heybox-up-reader .hb-bbs-post, body.heybox-up-reader .hb-bbs__video { display: none !important; } body.heybox-up-reader .cpt-right-side.right { display: none !important; } /* The reader sits at 4001, so lift the site confirmation dialog above it. */ body.heybox-up-reader .alert-container-wrapper .hb-cpt__alert-wrapper { z-index: 4100 !important; } #${READER_ID} { position: fixed; top: 0; left: 50%; z-index: 4001; width: min(calc(var(--hbu-reader-stage) + 80px), 100%); height: 100%; height: 100dvh; transform: translateX(-50%); background: var(--hbu-reader-page); color: var(--hbu-reader-ink); box-shadow: var(--hbu-reader-shadow); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; isolation: isolate; } #${READER_ID} *, #${READER_ID} *::before, #${READER_ID} *::after { box-sizing: border-box; } #${READER_ID} :is(button, a, [role="button"]) { -webkit-tap-highlight-color: transparent; } #${READER_ID} :is(button, a, [role="button"]):focus-visible { outline: 2px solid var(--hbu-reader-accent); outline-offset: 2px; } .hbu-reader-scroll { position: relative; width: calc(100% - 80px); /* The progress bar owns its own 46px band; scrolling content never paints under it. */ height: calc(100% - 46px); overflow-x: hidden; overflow-y: auto; margin-inline: auto; overscroll-behavior: contain; scrollbar-width: none; scroll-behavior: smooth; } .hbu-reader-scroll::-webkit-scrollbar { display: none; } .hbu-reader-page { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 0; padding: 44px 60px 72px; background: var(--hbu-reader-page); } /* 文本页不再用页内边距撑开段距:上下内边距归零,由段落自身的下间距形成 统一的 .72em 节奏,跨页处也只保留一次段间距,避免 64px 的叠加空隙。 */ .hbu-reader-page[data-kind="text"] { padding: 0 60px; } .hbu-reader-page[data-kind="heading"] { padding: 56px 60px 28px; } .hbu-reader-page[data-kind="hero"] { min-height: calc(100dvh - 46px); padding: 0; } .hbu-reader-page[data-kind="end"] { min-height: calc(100dvh - 46px); } .hbu-reader-page[data-kind="text"] .hbu-reader-copy, .hbu-reader-page[data-kind="heading"] .hbu-reader-copy { width: min(34em, 100%); font-size: 18px; line-height: var(--heybox-up-reading-line-height); color: var(--hbu-reader-ink); overflow-wrap: anywhere; } .hbu-reader-page[data-kind="text"] .hbu-reader-copy { white-space: normal; } .hbu-reader-page[data-kind="heading"] .hbu-reader-copy { white-space: pre-wrap; } .hbu-reader-page[data-kind="text"] .hbu-reader-copy p { margin: 0 0 .72em; } .hbu-reader-page[data-kind="heading"] { justify-content: center; } .hbu-reader-page[data-kind="heading"] .hbu-reader-copy { font-size: 27px; line-height: 1.42; font-weight: 700; letter-spacing: -.015em; } .hbu-reader-page[data-kind="rich"] { align-items: stretch; justify-content: flex-start; padding-top: 40px; } .hbu-reader-rich { width: 100%; font-size: 18px; line-height: var(--heybox-up-reading-line-height); color: var(--hbu-reader-ink); overflow-wrap: anywhere; } /* The cloned markup keeps a hard-coded 25px line height; cover body text only so cards and blockquotes keep their own styles. */ .hbu-reader-rich :is(p, li, ul, ol, .line, .order-line, .text):not([class*="com-"], [class*="com-"] *):not(blockquote, blockquote *) { line-height: var(--heybox-up-reading-line-height) !important; } .hbu-reader-rich :is(h1, h2, h3, h4, h5, h6) { margin: 0 0 .7em; font-size: 22px; line-height: 1.45; } .hbu-reader-rich :is(p, .text) { margin: 0 0 .72em; } .hbu-reader-rich :is(ul, ol, pre, blockquote, table) { margin: 0 0 1em; } .hbu-reader-rich :is(ul, ol) { padding-left: 1.35em; } .hbu-reader-rich li + li { margin-top: .45em; } .hbu-reader-rich blockquote { padding: 2px 0 2px 15px; border-left: 3px solid var(--hbu-reader-accent); color: var(--hbu-reader-ink-2); line-height: 1.8; } .hbu-reader-rich blockquote :is(p, .text) { line-height: 1.8 !important; } .hbu-reader-rich pre { padding: 14px 16px; border: 1px solid var(--hbu-reader-border); border-radius: 12px; background: var(--hbu-reader-subtle); overflow-x: auto; font: 13px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; } .hbu-reader-rich :is(img, video, .hb-cpt__image, .com-game-card, .com-album-card, .com-award-table-card, .com-chatroom-card, .com-league-card, .com-match-card, .com-mod-card, .com-sku-card, .com-wiki-card) { max-width: 100%; border-radius: 14px; } .hbu-reader-rich :is(.com-game-card, .com-album-card, .com-award-table-card, .com-chatroom-card, .com-league-card, .com-match-card, .com-mod-card, .com-sku-card, .com-wiki-card) { background: var(--hbu-reader-subtle) !important; border-color: var(--hbu-reader-border) !important; color: var(--hbu-reader-ink) !important; } .hbu-reader-rich :is(a, .prose-link) { color: var(--hbu-reader-link); } /* Cloned article nodes keep their original dark text colors on nested paragraphs and list lines. Cards and blockquotes keep their own colors, matching the line-height exclusion above. */ .hbu-reader-rich :is(p, li, ol, ul, .line, .order-line, .text):not([class*="com-"], [class*="com-"] *):not(blockquote, blockquote *) { color: var(--hbu-reader-ink) !important; } .hbu-reader-rich blockquote, .hbu-reader-rich blockquote :is(p, .text) { color: var(--hbu-reader-ink-2) !important; } /* Media pages keep their own breathing room above the dedicated progress band. */ .hbu-reader-page[data-kind="media"] { justify-content: flex-start; padding: 32px 20px 64px; background: #05070a; } .hbu-reader-page[data-kind="embed"] { justify-content: flex-start; padding: 32px 20px 64px; background: #05070a; } .hbu-reader-page[data-kind="video"] { justify-content: flex-start; padding: 32px 20px 64px; background: #05070a; } .hbu-reader-page[data-kind="notice"] { justify-content: center; padding: 32px 24px 72px; background: var(--hbu-reader-page); } .hbu-reader-notice { width: min(520px, 100%); padding: 22px; border: 1px solid var(--hbu-reader-border); border-radius: 16px; background: var(--hbu-reader-subtle); color: var(--hbu-reader-ink); text-align: center; } .hbu-reader-notice h2 { margin: 0 0 8px; font-size: 17px; line-height: 1.4; } .hbu-reader-notice p { margin: 0; color: var(--hbu-reader-muted); font-size: 13px; line-height: 1.6; } .hbu-reader-notice a { display: inline-flex; align-items: center; min-height: 36px; margin-top: 14px; padding: 0 14px; border-radius: 9px; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font-size: 12px; font-weight: 650; text-decoration: none; } .hbu-reader-embed { width: min(100%, 960px); margin: auto; overflow: hidden; background: #05070a; border-radius: 12px; } .hbu-reader-embed > * { width: 100% !important; height: 100% !important; margin: 0 !important; border: 0 !important; border-radius: 0 !important; } .hbu-reader-embed :is(iframe, video, embed, object) { display: block !important; width: 100% !important; height: 100% !important; border: 0 !important; } .hbu-reader-embed audio { display: block !important; width: 100% !important; height: auto !important; min-height: 54px !important; border: 0 !important; } .hbu-reader-media { display: block; max-width: 100%; max-height: none; width: auto; height: auto; object-fit: contain; margin: auto; border-radius: 12px; cursor: zoom-in; box-shadow: 0 18px 50px rgba(0, 0, 0, .35); } .hbu-reader-video { display: block; width: min(100%, 1120px); max-height: calc(100dvh - 150px); margin: auto; aspect-ratio: 16 / 9; border-radius: 12px; background: #000; object-fit: contain; box-shadow: 0 18px 50px rgba(0, 0, 0, .35); } .hbu-reader-page[data-kind="hero"] { padding: 0; justify-content: flex-start; align-items: stretch; min-height: calc(100dvh - 46px); background: var(--hbu-reader-page); color: var(--hbu-reader-ink); } /* The cover is a stage plus a pager band: the band always owns its own row under the photo. */ .hbu-reader-cover { position: relative; inset: auto; display: flex; flex-direction: column; flex: 1 1 auto; min-height: 240px; overflow: hidden; background: #05070a; } .hbu-reader-cover-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; } .hbu-reader-cover-glow { position: absolute; inset: -48px; background-position: center; background-size: cover; filter: blur(42px) brightness(.5); transform: scale(1.1); } .hbu-reader-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity .32s ease; pointer-events: none; } .hbu-reader-cover img[data-active="true"] { opacity: 1; pointer-events: auto; } .hbu-reader-cover-stage::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, .52), transparent 24%); } .hbu-reader-page[data-kind="hero"][data-cover="none"] { justify-content: center; } .hbu-reader-cover-nav { position: absolute; top: 50%; z-index: 2; display: grid; place-items: center; width: 40px; height: 40px; margin-top: -20px; border: 1px solid rgba(255, 255, 255, .22); border-radius: 50%; background: rgba(8, 11, 15, .46); color: #fff; cursor: pointer; } .hbu-reader-cover-nav svg { width: 19px; height: 19px; } .hbu-reader-cover-nav[data-side="prev"] { left: 16px; } .hbu-reader-cover-nav[data-side="next"] { right: 16px; } .hbu-reader-cover-nav[hidden] { display: none; } /* A blank strip below the photo holds the pager, so a dot never covers the picture. */ .hbu-reader-cover-progress { position: relative; z-index: 2; display: flex; flex: none; align-items: center; justify-content: center; height: 32px; background: #05070a; } .hbu-reader-cover-progress[hidden] { display: none; } .hbu-reader-cover-dots { display: flex; gap: 0; align-items: center; max-width: 100%; height: 100%; overflow-x: auto; scrollbar-width: none; } .hbu-reader-cover-dots::-webkit-scrollbar { display: none; } .hbu-reader-cover-dots button { position: relative; width: 36px; height: 32px; padding: 0; border: 0; background: transparent; cursor: pointer; } .hbu-reader-cover-dots button::after { content: ""; position: absolute; top: 50%; right: 8px; left: 8px; height: 3px; margin-top: -1.5px; border-radius: 99px; background: rgba(255, 255, 255, .38); } .hbu-reader-cover-dots button[aria-current="true"]::after { background: #fff; } .hbu-reader-caption { position: relative; z-index: 2; flex: none; width: 100%; margin: 0; padding: 22px 60px 64px; border: 0; border-radius: 0; background: var(--hbu-reader-page); color: var(--hbu-reader-ink); } .hbu-reader-kicker { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin-bottom: 13px; color: var(--hbu-reader-muted); font-size: 12px; line-height: 1.4; } .hbu-reader-kicker span { padding: 3px 9px; border: 1px solid var(--hbu-reader-border); border-radius: 99px; background: var(--hbu-reader-subtle); } .hbu-reader-title { display: -webkit-box; margin: 0 0 12px; overflow: hidden; color: var(--hbu-reader-ink); font-size: 31px; line-height: 1.28; font-weight: 750; letter-spacing: -.02em; text-wrap: balance; -webkit-box-orient: vertical; -webkit-line-clamp: 4; } .hbu-reader-byline { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; color: var(--hbu-reader-muted); font-size: 13px; } .hbu-reader-byline b { color: var(--hbu-reader-ink); font-weight: 600; } .hbu-reader-page[data-kind="end"] { justify-content: center; gap: 22px; background: var(--hbu-reader-page); } .hbu-reader-end-card { width: min(440px, 100%); padding: 24px; border: 1px solid var(--hbu-reader-border); border-radius: 20px; background: var(--hbu-reader-subtle); } .hbu-reader-end-head { display: flex; gap: 12px; align-items: center; } .hbu-reader-end-head img { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; background: var(--hbu-reader-page); } .hbu-reader-end-name { min-width: 0; flex: 1 1 auto; } .hbu-reader-end-name b { display: block; font-size: 15px; } .hbu-reader-end-name span { color: var(--hbu-reader-muted); font-size: 12px; } .hbu-reader-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 20px 0 18px; } .hbu-reader-stats div { padding: 10px 6px; border-radius: 12px; background: var(--hbu-reader-page); text-align: center; } .hbu-reader-stats b { display: block; font-size: 17px; } .hbu-reader-stats span { color: var(--hbu-reader-muted); font-size: 11px; } .hbu-reader-tags { display: flex; flex-wrap: wrap; gap: 6px; } .hbu-reader-tag { min-height: 36px; padding: 7px 12px; border: 1px solid var(--hbu-reader-border); border-radius: 99px; background: transparent; color: var(--hbu-reader-ink-2); font: inherit; font-size: 12px; cursor: pointer; } .hbu-reader-related { width: min(440px, 100%); } .hbu-reader-related h2 { margin: 0 0 8px; color: var(--hbu-reader-muted); font-size: 11px; font-weight: 600; letter-spacing: .08em; } .hbu-reader-related a { display: flex; gap: 10px; min-height: 44px; padding: 11px 13px; border: 1px solid var(--hbu-reader-border); border-radius: 12px; color: var(--hbu-reader-ink); font-size: 13px; line-height: 1.5; text-decoration: none; } .hbu-reader-related a + a { margin-top: 6px; } .hbu-reader-related a:hover { background: var(--hbu-reader-subtle); } .hbu-reader-end-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } .hbu-reader-end-actions button { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 8px 14px; border: 1px solid var(--hbu-reader-border); border-radius: 99px; background: transparent; color: var(--hbu-reader-ink); font: inherit; font-size: 13px; cursor: pointer; } .hbu-reader-end-actions button[data-home] { border-color: transparent; background: var(--hbu-reader-ink); color: var(--hbu-reader-page); font-weight: 650; } .hbu-reader-end-actions button:disabled { opacity: .42; cursor: default; } .hbu-reader-end-actions button svg { width: 15px; height: 15px; } .hbu-reader-end-status { margin: 0; color: var(--hbu-reader-muted); font-size: 12px; line-height: 1.5; text-align: center; } .hbu-reader-end-status[hidden] { display: none; } @media (max-height: 820px) { .hbu-reader-page[data-kind="end"] { gap: 12px; padding-top: 64px; padding-bottom: 64px; } .hbu-reader-page[data-kind="end"] .hbu-reader-related { display: none; } .hbu-reader-end-card { padding: 18px; } .hbu-reader-stats { margin: 14px 0 12px; } } @media (max-height: 520px) { .hbu-reader-page[data-kind="end"] { padding-top: 48px; padding-bottom: 40px; } .hbu-reader-page[data-kind="end"] .hbu-reader-stats { display: none; } .hbu-reader-end-head img { width: 40px; height: 40px; } } .hbu-reader-head { position: absolute; top: 0; right: 0; left: 0; z-index: 12; display: flex; gap: 9px; align-items: center; padding: 14px 16px; padding-top: max(14px, env(safe-area-inset-top)); background: var(--hbu-reader-head); color: var(--hbu-reader-glass-ink); pointer-events: none; } .hbu-reader-head > * { pointer-events: auto; } .hbu-reader-round { display: grid; place-items: center; width: 40px; height: 40px; flex: none; padding: 0; border: 1px solid var(--hbu-reader-glass-border); border-radius: 50%; background: var(--hbu-reader-glass); color: var(--hbu-reader-glass-ink); cursor: pointer; backdrop-filter: blur(8px); } .hbu-reader-round svg { width: 19px; height: 19px; } .hbu-reader-home { display: inline-flex; align-items: center; gap: 6px; height: 40px; flex: none; padding: 0 13px 0 10px; border: 1px solid var(--hbu-reader-glass-border); border-radius: 99px; background: var(--hbu-reader-glass); color: var(--hbu-reader-glass-ink); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; backdrop-filter: blur(8px); } .hbu-reader-home svg { width: 17px; height: 17px; flex: none; } .hbu-reader-head-author { display: flex; gap: 7px; align-items: center; min-width: 0; max-width: 42%; padding: 4px 10px 4px 4px; border: 1px solid var(--hbu-reader-glass-border); border-radius: 99px; background: var(--hbu-reader-glass); color: var(--hbu-reader-glass-ink); text-decoration: none; backdrop-filter: blur(8px); } .hbu-reader-head-author img { width: 30px; height: 30px; flex: none; border-radius: 50%; object-fit: cover; background: var(--hbu-reader-subtle); } .hbu-reader-head-author span { min-width: 0; overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .hbu-reader-follow { min-width: 56px; height: 36px; padding: 0 13px; border: 0; border-radius: 99px; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; } .hbu-reader-follow[aria-pressed="true"] { background: var(--hbu-reader-float-hover); color: var(--hbu-reader-glass-ink); } .hbu-reader-head-spacer { flex: 1 1 auto; } .hbu-reader-rail { position: absolute; right: 16px; bottom: 118px; z-index: 11; display: flex; flex-direction: column; gap: 13px; align-items: center; } .hbu-reader-action { display: grid; place-items: center; width: 48px; min-height: 48px; padding: 4px 0; border: 1px solid var(--hbu-reader-glass-border); border-radius: 50%; background: var(--hbu-reader-glass); color: var(--hbu-reader-glass-ink); cursor: pointer; backdrop-filter: blur(10px); } .hbu-reader-action:hover { background: var(--hbu-reader-float-hover); } .hbu-reader-action svg { width: 21px; height: 21px; } .hbu-reader-action[aria-pressed="true"] { color: var(--hbu-reader-active-like); } .hbu-reader-action[data-action="favour"][aria-pressed="true"] { color: var(--hbu-reader-active-favour); } .hbu-reader-action[data-action="cy"][aria-pressed="true"] { color: var(--hbu-reader-active-cy); } .hbu-reader-action--cy { display: flex; flex-direction: column; gap: 1px; align-items: center; justify-content: center; } .hbu-reader-cy-label { font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: .02em; } .hbu-reader-cy-count { font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: .01em; opacity: .72; font-variant-numeric: tabular-nums; } .hbu-reader-rail-avatar { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } .hbu-reader-favour-menu { position: absolute; right: calc(100% + 10px); bottom: 92px; z-index: 2; display: flex; flex-direction: column; gap: 8px; width: 248px; max-width: calc(100vw - 84px); max-height: min(420px, 64dvh); padding: 10px; border: 1px solid var(--hbu-reader-float-border); border-radius: 14px; background: var(--hbu-reader-float); color: var(--hbu-reader-float-ink); box-shadow: var(--hbu-reader-shadow); backdrop-filter: blur(12px); } .hbu-reader-favour-menu[hidden] { display: none; } .hbu-reader-favour-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .hbu-reader-favour-head > span { font-size: 13px; font-weight: 600; } .hbu-reader-favour-close { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 16px; line-height: 1; cursor: pointer; } .hbu-reader-favour-close:hover { background: var(--hbu-reader-float-hover); } .hbu-reader-favour-status { min-height: 16px; font-size: 12px; line-height: 1.5; color: var(--hbu-reader-float-muted); } .hbu-reader-favour-status[data-tone="error"] { color: var(--hbu-reader-danger); } .hbu-reader-favour-list { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; } .hbu-reader-favour-option { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; } .hbu-reader-favour-option:hover { background: var(--hbu-reader-float-hover); } .hbu-reader-favour-option input { flex: none; width: 14px; height: 14px; margin: 0; accent-color: var(--hbu-reader-accent); cursor: pointer; } .hbu-reader-favour-name { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .hbu-reader-favour-tag { flex: none; padding: 1px 5px; border-radius: 5px; background: var(--hbu-reader-float-hover); color: var(--hbu-reader-float-muted); font-size: 10px; } .hbu-reader-favour-empty { padding: 8px; color: var(--hbu-reader-float-muted); font-size: 12px; line-height: 1.5; } .hbu-reader-favour-create { display: flex; gap: 6px; } .hbu-reader-favour-create-input { min-width: 0; flex: 1 1 auto; height: 30px; padding: 0 8px; border: 1px solid var(--hbu-reader-float-border); border-radius: 8px; outline: none; background: var(--hbu-reader-float-hover); color: inherit; font: inherit; font-size: 12px; } .hbu-reader-favour-create-input:focus { border-color: var(--hbu-reader-accent); } .hbu-reader-favour-create-btn, .hbu-reader-favour-save { height: 30px; padding: 0 12px; border: 1px solid var(--hbu-reader-float-border); border-radius: 8px; background: var(--hbu-reader-float-hover); color: inherit; font: inherit; font-size: 12px; cursor: pointer; } .hbu-reader-favour-create-btn:hover:not(:disabled) { border-color: var(--hbu-reader-accent); } .hbu-reader-favour-actions { display: flex; justify-content: flex-end; } .hbu-reader-favour-save { min-width: 64px; border-color: var(--hbu-reader-accent); background: var(--hbu-reader-accent); color: #fff; font-weight: 600; } .hbu-reader-favour-save:hover:not(:disabled) { filter: brightness(1.08); } .hbu-reader-favour-menu :is(button, input):disabled { cursor: default; opacity: .5; } .hbu-reader-spacing-menu { position: absolute; right: calc(100% + 10px); bottom: 0; z-index: 2; width: 196px; max-width: calc(100vw - 84px); padding: 10px; border: 1px solid var(--hbu-reader-float-border); border-radius: 14px; background: var(--hbu-reader-float); color: var(--hbu-reader-float-ink); box-shadow: var(--hbu-reader-shadow); backdrop-filter: blur(12px); } .hbu-reader-spacing-menu[hidden] { display: none; } .hbu-reader-spacing-label { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: var(--hbu-reader-float-ink); } .hbu-reader-spacing-row { display: flex; gap: 7px; align-items: center; } .hbu-reader-spacing-row input { width: 70px; height: 32px; padding: 0 8px; border: 1px solid var(--hbu-reader-float-border); border-radius: 8px; outline: none; background: var(--hbu-reader-float-hover); color: inherit; font: inherit; font-size: 13px; font-variant-numeric: tabular-nums; } .hbu-reader-spacing-row input:focus { border-color: var(--hbu-reader-accent); } .hbu-reader-spacing-unit { font-size: 12px; color: var(--hbu-reader-float-muted); } .hbu-reader-spacing-reset { height: 32px; margin-left: auto; padding: 0 9px; border: 1px solid var(--hbu-reader-float-border); border-radius: 8px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; } .hbu-reader-spacing-reset:hover { background: var(--hbu-reader-float-hover); } .hbu-reader-cy-dialog { position: absolute; inset: 0; z-index: 60; display: grid; place-items: center; padding: 24px; background: var(--hbu-reader-scrim); backdrop-filter: blur(4px); } .hbu-reader-cy-dialog[hidden] { display: none; } .hbu-reader-cy-card { width: min(520px, 100%); padding: 20px; border: 1px solid var(--hbu-reader-border); border-radius: 20px; background: var(--hbu-reader-page); color: var(--hbu-reader-ink); box-shadow: var(--hbu-reader-shadow); } .hbu-reader-cy-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 15px; font-weight: 700; } .hbu-reader-cy-close { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--hbu-reader-border); border-radius: 50%; background: transparent; color: inherit; cursor: pointer; } .hbu-reader-cy-close svg { width: 16px; height: 16px; } .hbu-reader-cy-text { width: 100%; min-height: 132px; padding: 12px; resize: vertical; border: 1px solid var(--hbu-reader-border); border-radius: 12px; outline: none; background: var(--hbu-reader-subtle); color: inherit; font: inherit; line-height: 1.6; } .hbu-reader-cy-text:focus { border-color: var(--hbu-reader-accent); } .hbu-reader-cy-status { min-height: 18px; margin-top: 8px; color: var(--hbu-reader-muted); font-size: 12px; } .hbu-reader-cy-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; } .hbu-reader-cy-actions button { min-height: 36px; padding: 0 16px; border: 1px solid var(--hbu-reader-border); border-radius: 10px; background: transparent; color: inherit; font: inherit; font-size: 13px; cursor: pointer; } .hbu-reader-cy-actions button[data-primary] { border-color: transparent; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font-weight: 650; } .hbu-reader-cy-actions button:disabled { opacity: .55; cursor: default; } .hbu-reader-action-count { display: block; margin-top: 2px; font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.2; } .hbu-reader-progress { position: absolute; right: 0; bottom: 0; left: 0; z-index: 10; display: flex; gap: 10px; align-items: center; height: 46px; padding: 0 18px; background: linear-gradient(0deg, var(--hbu-reader-glass), transparent); color: var(--hbu-reader-glass-ink); font-size: 11px; font-variant-numeric: tabular-nums; pointer-events: none; } .hbu-reader-progress-bar { flex: 1 1 auto; height: 2px; overflow: hidden; border-radius: 99px; background: var(--hbu-reader-glass-border); } .hbu-reader-progress-bar i { display: block; width: 0; height: 100%; background: var(--hbu-reader-accent); transition: width .2s linear; } .hbu-reader-hint { color: var(--hbu-reader-float-muted); white-space: nowrap; } body.${READER_CLASS} #${PAGER_ID} { display: none !important; } .hbu-reader-comments-panel { position: fixed; inset: 0; z-index: 4065; display: flex; justify-content: flex-end; } .hbu-reader-comments-panel[hidden] { display: none; } .hbu-reader-comments-scrim { position: absolute; inset: 0; background: var(--hbu-reader-scrim); } .hbu-reader-comments-sheet { position: relative; z-index: 1; display: flex; flex-direction: column; width: min(440px, 100vw); height: 100%; height: 100dvh; border-left: 1px solid var(--hbu-reader-border); background: var(--hbu-reader-page); color: var(--hbu-reader-ink); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; box-shadow: var(--hbu-reader-shadow); } .hbu-reader-comments-panel :is(button, a):focus-visible { outline: 2px solid var(--hbu-reader-accent); outline-offset: 2px; } .hbu-reader-comments-head { display: flex; gap: 12px; align-items: center; padding: 18px 16px 13px; border-bottom: 1px solid var(--hbu-reader-border); } .hbu-reader-comments-heading { min-width: 0; flex: 1 1 auto; } .hbu-reader-comments-heading b { display: block; font-size: 15px; } .hbu-reader-comments-heading span { color: var(--hbu-reader-muted); font-size: 11px; } .hbu-reader-comments-close { display: grid; place-items: center; width: 36px; height: 36px; flex: none; padding: 0; border: 1px solid var(--hbu-reader-border); border-radius: 50%; background: transparent; color: inherit; cursor: pointer; } .hbu-reader-comments-close svg { width: 16px; height: 16px; } .hbu-reader-comments-refresh { display: grid; place-items: center; width: 36px; height: 36px; flex: none; padding: 0; border: 1px solid var(--hbu-reader-border); border-radius: 50%; background: transparent; color: var(--hbu-reader-muted); cursor: pointer; } .hbu-reader-comments-refresh:hover { color: inherit; border-color: var(--hbu-reader-accent); } .hbu-reader-comments-refresh:disabled { opacity: .55; cursor: default; } .hbu-reader-comments-refresh svg { width: 16px; height: 16px; } .hbu-reader-comments-refresh[data-busy="true"] svg { animation: hbu-reader-spin .9s linear infinite; } @keyframes hbu-reader-spin { to { transform: rotate(360deg); } } .hbu-reader-comments-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 16px 18px; } .hbu-reader-comments-list { margin: 0; padding: 0; list-style: none; } .hbu-reader-comments-item { display: flex; gap: 10px; padding: 14px 0; border-bottom: 1px solid var(--hbu-reader-border); } .hbu-reader-comments-item:last-child { border-bottom: 0; } .hbu-reader-comments-avatar-link { display: block; width: 34px; height: 34px; flex: none; border-radius: 50%; } .hbu-reader-comments-avatar-link--child { width: 24px; height: 24px; } .hbu-reader-comments-avatar { display: block; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; background: var(--hbu-reader-subtle); } .hbu-reader-comments-main { min-width: 0; flex: 1 1 auto; } .hbu-reader-comments-who { display: flex; flex-wrap: wrap; gap: 7px; align-items: baseline; } .hbu-reader-comments-name { font-size: 13px; font-weight: 650; } .hbu-reader-comments-name-link { color: inherit; text-decoration: none; } .hbu-reader-comments-name-link:hover .hbu-reader-comments-name { color: var(--hbu-reader-link); text-decoration: underline; } .hbu-reader-comments-time { color: var(--hbu-reader-muted); font-size: 11px; } .hbu-reader-comments-text { margin: 4px 0 0; font-size: 14px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; } .hbu-reader-comments-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 5px; color: var(--hbu-reader-muted); font-size: 11px; } .hbu-reader-comments-reply { flex: none; padding: 0; border: 0; background: transparent; color: var(--hbu-reader-muted); font: inherit; font-size: 11px; cursor: pointer; } .hbu-reader-comments-reply:hover { color: var(--hbu-reader-link); } .hbu-reader-comments-delete { flex: none; padding: 0; border: 0; background: transparent; color: var(--hbu-reader-muted); font: inherit; font-size: 11px; cursor: pointer; } .hbu-reader-comments-delete:hover { color: var(--hbu-reader-danger); } .hbu-reader-comments-delete:disabled { opacity: .55; cursor: default; } .hbu-reader-comments-children { margin: 8px 0 0; padding: 0 0 0 13px; border-left: 1px solid var(--hbu-reader-border); list-style: none; } .hbu-reader-comments-child { position: relative; padding: 7px 0 0; } .hbu-reader-comments-child::before { content: ""; position: absolute; top: 19px; left: -13px; width: 11px; height: 1px; background: var(--hbu-reader-border); } .hbu-reader-comments-child:first-child { padding-top: 0; } .hbu-reader-comments-child:first-child::before { top: 12px; } .hbu-reader-comments-child-row { display: flex; gap: 8px; align-items: flex-start; } .hbu-reader-comments-child-main { min-width: 0; flex: 1 1 auto; } .hbu-reader-comments-reply-to { color: var(--hbu-reader-muted); font-size: 11px; } .hbu-reader-comments-child .hbu-reader-comments-text { margin-top: 2px; font-size: 13px; } .hbu-reader-comments-child .hbu-reader-comments-meta { margin-top: 3px; } .hbu-reader-comments-collapse { flex: none; padding: 0; border: 0; background: transparent; color: var(--hbu-reader-muted); font: inherit; font-size: 11px; cursor: pointer; } .hbu-reader-comments-collapse:hover { color: var(--hbu-reader-link); } /* A folded branch keeps a short indent and a dashed rule, so the row still shows that replies are stored underneath it. */ .hbu-reader-comments-children--collapsed { padding-left: 6px; border-left-style: dashed; } .hbu-reader-comments-child--collapsed { padding-top: 0; } .hbu-reader-comments-child--collapsed::before { display: none; } .hbu-reader-comments-expand { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 2px 9px; border: 1px dashed var(--hbu-reader-border); border-radius: 999px; background: transparent; color: var(--hbu-reader-muted); font: inherit; font-size: 11px; cursor: pointer; } .hbu-reader-comments-expand::before { content: "+"; font-weight: 700; } .hbu-reader-comments-expand:hover { border-color: var(--hbu-reader-link); color: var(--hbu-reader-link); } .hbu-reader-comments-syncing { flex: none; color: var(--hbu-reader-muted); font-size: 11px; opacity: .85; } .hbu-reader-comments-syncing--settled { opacity: .7; } /* Past the depth cap the branch stops indenting, so a long reply chain can never push the sheet wider than its own column. */ .hbu-reader-comments-children--deep { margin-top: 6px; padding-left: 0; border-left: 0; } .hbu-reader-comments-children--deep > .hbu-reader-comments-child::before { display: none; } .hbu-reader-comments-text .hbu-reader-comments-emoji { display: inline-block; width: 17px; height: 17px; margin: 0 1px; vertical-align: -3px; background-repeat: no-repeat; background-size: 102px 306px; } .hbu-reader-comments-text img { max-width: 100%; max-height: 180px; margin-top: 6px; border-radius: 8px; object-fit: contain; vertical-align: top; } .hbu-reader-comments-status { display: grid; justify-items: center; gap: 10px; padding: 32px 12px; color: var(--hbu-reader-muted); font-size: 13px; text-align: center; } .hbu-reader-comments-status-retry { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 16px; border: 1px solid var(--hbu-reader-border); border-radius: 9px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; } .hbu-reader-comments-status-retry:hover { border-color: var(--hbu-reader-accent); } .hbu-reader-comments-foot { display: flex; gap: 8px; align-items: center; padding: 12px 16px; border-top: 1px solid var(--hbu-reader-border); } .hbu-reader-comments-foot button, .hbu-reader-comments-foot a { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 14px; border: 1px solid var(--hbu-reader-border); border-radius: 9px; background: transparent; color: inherit; font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } .hbu-reader-comments-foot a { margin-left: auto; border-color: transparent; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font-weight: 650; } .hbu-reader-comments-foot button:disabled { opacity: .55; cursor: default; } .hbu-reader-comments-list[hidden], .hbu-reader-comments-status[hidden], .hbu-reader-comments-status [hidden], .hbu-reader-comments-foot button[hidden] { display: none; } .hbu-reader-composer { display: flex; flex: none; flex-direction: column; gap: 7px; padding: 10px 16px 12px; border-bottom: 1px solid var(--hbu-reader-border); background: var(--hbu-reader-page); } .hbu-reader-composer-input { box-sizing: border-box; width: 100%; min-height: 58px; max-height: 132px; padding: 8px 11px; resize: none; border: 1px solid var(--hbu-reader-border); border-radius: 10px; outline: none; background: var(--hbu-reader-subtle); color: var(--hbu-reader-ink); font: 13px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .hbu-reader-composer-input::placeholder { color: var(--hbu-reader-muted); } .hbu-reader-composer-input:focus { border-color: var(--hbu-reader-accent); } .hbu-reader-composer-reply { display: flex; gap: 8px; align-items: center; min-width: 0; padding: 5px 9px; border-radius: 8px; background: var(--hbu-reader-subtle); color: var(--hbu-reader-ink-2); font-size: 11px; } .hbu-reader-composer-reply[hidden] { display: none; } .hbu-reader-composer-reply-name { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hbu-reader-composer-reply-cancel { flex: none; padding: 0; border: 0; background: transparent; color: var(--hbu-reader-link); font: inherit; cursor: pointer; } .hbu-reader-composer-bar { display: flex; gap: 8px; align-items: center; } .hbu-reader-composer-emoji-toggle { flex: none; min-height: 30px; padding: 0 10px; border: 1px solid var(--hbu-reader-border); border-radius: 8px; background: transparent; color: var(--hbu-reader-ink-2); font: inherit; font-size: 12px; cursor: pointer; } .hbu-reader-composer-emoji-toggle[aria-expanded="true"] { border-color: var(--hbu-reader-accent); color: var(--hbu-reader-ink); } .hbu-reader-composer-emoji { max-height: 196px; padding: 8px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--hbu-reader-border); border-radius: 10px; background: var(--hbu-reader-subtle); } .hbu-reader-composer-emoji[hidden] { display: none; } .hbu-reader-composer-emoji-grid { display: grid; grid-template-columns: repeat(6, 26px); gap: 6px; justify-content: center; } .hbu-reader-composer-emoji-grid button { display: grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 6px; background: transparent; cursor: pointer; } .hbu-reader-composer-emoji-grid button:hover, .hbu-reader-composer-emoji-grid button:focus-visible { background: var(--hbu-reader-page); } .hbu-reader-composer-emoji-grid span { display: block; width: 22px; height: 22px; background-repeat: no-repeat; } .hbu-reader-composer-status { min-width: 0; flex: 1 1 auto; color: var(--hbu-reader-muted); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; } .hbu-reader-composer-count { flex: none; color: var(--hbu-reader-muted); font-size: 11px; font-variant-numeric: tabular-nums; } .hbu-reader-composer-submit { flex: none; min-height: 30px; padding: 0 13px; border: 1px solid transparent; border-radius: 8px; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; } .hbu-reader-composer-submit:disabled { opacity: .55; cursor: default; } .hbu-reader-lightbox { position: fixed; inset: 0; z-index: 4050; display: grid; place-items: center; padding: 24px; border: 0; background: rgba(2, 4, 7, .94); cursor: zoom-out; } .hbu-reader-lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; } .hbu-reader-toast { position: absolute; bottom: 58px; left: 50%; z-index: 20; padding: 7px 13px; border: 1px solid var(--hbu-reader-float-border); border-radius: 99px; background: var(--hbu-reader-float); color: var(--hbu-reader-float-ink); font-size: 12px; box-shadow: 0 10px 26px rgba(0, 0, 0, .18); opacity: 0; pointer-events: none; transform: translate(-50%, 8px); transition: opacity .18s ease, transform .18s ease; } .hbu-reader-toast[data-show="true"] { opacity: 1; transform: translate(-50%, 0); } .hbu-reader-toast--fixed { position: fixed; bottom: 58px; z-index: 4060; } .hbu-reader-pending { display: grid; height: 100%; place-items: center; padding: 24px; color: var(--hbu-reader-muted); font-size: 13px; letter-spacing: .04em; } .hbu-reader-pending-card { display: flex; flex-direction: column; gap: 12px; align-items: center; text-align: center; } .hbu-reader-pending-text { margin: 0; } .hbu-reader-pending-actions { display: flex; gap: 8px; align-items: center; } .hbu-reader-pending-actions[hidden] { display: none; } .hbu-reader-pending-actions button { min-height: 34px; padding: 0 14px; border: 1px solid var(--hbu-reader-border); border-radius: 9px; background: var(--hbu-reader-page); color: var(--hbu-reader-ink); font: inherit; letter-spacing: 0; cursor: pointer; } .hbu-reader-pending-actions button[data-retry] { border-color: transparent; background: var(--hbu-reader-accent); color: var(--hbu-reader-on-accent); font-weight: 650; } @media (max-width: 900px) { #${READER_ID} { width: 100%; } .hbu-reader-scroll { width: 100%; } .hbu-reader-rail { right: 14px; bottom: 96px; } .hbu-reader-page { padding-right: 86px; } .hbu-reader-caption { padding-right: 86px; } } @media (max-width: 760px) { :root { --hbu-reader-stage: 100%; } .hbu-reader-rail-avatar-link { display: none; } .hbu-reader-page { padding: 22px 64px 48px 24px; } .hbu-reader-page[data-kind="hero"] { padding: 0; } .hbu-reader-page[data-kind="text"] { padding: 0 64px 0 24px; } .hbu-reader-page[data-kind="heading"] { padding: 44px 64px 24px 24px; } .hbu-reader-page[data-kind="media"] { padding: 20px 16px 62px; } .hbu-reader-page[data-kind="embed"] { padding: 20px 16px 62px; } .hbu-reader-page[data-kind="video"] { padding: 20px 16px 62px; } .hbu-reader-page[data-kind="text"] .hbu-reader-copy, .hbu-reader-page[data-kind="heading"] .hbu-reader-copy, .hbu-reader-rich { font-size: 17px; } .hbu-reader-page[data-kind="heading"] .hbu-reader-copy { font-size: 24px; } .hbu-reader-caption { padding: 18px 64px 56px 24px; } .hbu-reader-title { font-size: 26px; } .hbu-reader-cy-text { font-size: 16px; } .hbu-reader-rail { gap: 9px; bottom: 88px; right: 10px; } .hbu-reader-action { width: 42px; min-height: 42px; } .hbu-reader-action svg { width: 19px; height: 19px; } .hbu-reader-head { padding: 10px 12px; padding-top: max(10px, env(safe-area-inset-top)); } .hbu-reader-head-author { max-width: 38%; } .hbu-reader-progress { padding: 0 12px; } .hbu-reader-round { width: 38px; height: 38px; } .hbu-reader-home { height: 38px; padding: 0 11px 0 9px; } } @media (max-width: 520px) { .hbu-reader-head-author span { display: none; } .hbu-reader-head-author { padding-right: 4px; } .hbu-reader-follow { min-width: 48px; padding: 0 10px; } .hbu-reader-home { width: 38px; justify-content: center; padding: 0; border-radius: 50%; } .hbu-reader-home span { display: none; } .hbu-reader-page { padding-right: 64px; } .hbu-reader-page[data-kind="media"] { padding-right: 16px; padding-left: 16px; } .hbu-reader-page[data-kind="embed"] { padding-right: 16px; padding-left: 16px; } .hbu-reader-caption { padding-right: 64px; } .hbu-reader-hint { display: none; } .hbu-reader-related { display: none; } } @media (max-height: 620px) { .hbu-reader-page { padding-top: 72px; padding-bottom: 76px; } .hbu-reader-page[data-kind="hero"] { padding: 0; } .hbu-reader-page[data-kind="text"] { padding-top: 0; padding-bottom: 0; } .hbu-reader-caption { padding: 14px 64px 58px 24px; } .hbu-reader-rail { bottom: 70px; gap: 7px; } .hbu-reader-action { width: 38px; min-height: 38px; } .hbu-reader-action-count { display: none; } } @media (prefers-reduced-motion: reduce) { .hbu-reader-scroll { scroll-behavior: auto; } #${READER_ID} *, #${READER_ID} *::before, #${READER_ID} *::after { transition: none !important; } } /* The progress bar is now a sibling band, not an overlay on a page. */ `; var PAGER_ICONS = { prev: '', next: '' }; function readPostLinkId() { const match = /^\/app\/bbs\/link\/(\d+)/.exec(location.pathname); return match ? match[1] : ""; } function readSessionArray(key) { const value = sessionStore.readJson(key, []); return Array.isArray(value) ? value : []; } function writeSessionValue(key, value) { return sessionStore.writeJson(key, value); } function readFeedOrder(accountId = getHeyboxId()) { return mergeFeedOrder(readSessionArray(buildFeedStateKey(FEED_ORDER_KEY, accountId)), []); } function recordFeedOrder() { if (readPostLinkId() !== "") return; if (!document.querySelector("#page-bbs-community .bbs-home__content-list")) return; const seen = /* @__PURE__ */ new Set(); const list = []; for (const anchor of document.querySelectorAll("#page-bbs-community .bbs-home__content-list a[href*='/app/bbs/link/']")) { const matched = /link\/(\d+)/.exec(anchor.getAttribute("href") || ""); if (!matched) continue; const matchId = matched[1]; if (seen.has(matchId)) continue; seen.add(matchId); const titleNode = anchor.querySelector(".bbs-home__content-title, .link-item__title, .bbs-home__topic-name"); const title = (titleNode ? titleNode.textContent : anchor.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80); const card = anchor.closest(".bbs-home__content-item, .content-list__item, li"); const meta = readPostMeta(card === null ? anchor : card, title); if (meta === null) continue; list.push({ id: matchId, title: meta.title || title, authorId: meta.authorId, author: meta.author, topicIds: meta.topicIds, topics: meta.topics }); } if (list.length === 0) return; const accountId = getHeyboxId(); const orderKey = buildFeedStateKey(FEED_ORDER_KEY, accountId); const merged = mergeFeedOrder(readSessionArray(orderKey), list).slice(-FEED_ORDER_MAX); if (merged.length > 0) writeSessionValue(orderKey, merged); } function readRelatedPostLinks() { const seen = /* @__PURE__ */ new Set(); const list = []; const nodes = document.querySelectorAll(".bbs-link__related-recommend a[href*='/app/bbs/link/'], .related-recommend__container a[href*='/app/bbs/link/']"); for (const anchor of nodes) { const matched = /link\/(\d+)/.exec(anchor.getAttribute("href") || ""); if (!matched) continue; const matchId = matched[1]; if (seen.has(matchId)) continue; seen.add(matchId); const title = (anchor.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80); list.push({ id: matchId, title }); } return list; } function readUserIdFromHref(href) { const value = typeof href === "string" ? href : ""; if (value === "") return ""; const match = /\/app\/user\/profile\/(\d+)/.exec(value) || /[?&]userid=(\d+)/.exec(value); return match ? normalizeFeedId(match[1]) : ""; } function readPostMeta(root, title) { if (!(root instanceof Element)) return null; const scope = root; const authorNode = scope.querySelector(".list-content__username, .link-user__username, .page-header__username"); const authorLink = scope.querySelector(".header__user, .link-user__user-wrapper, .page-header__user-wrapper, a[href*='/app/user/profile/']"); const authorHref = authorLink?.getAttribute("href") || authorLink?.querySelector("a[href*='/app/user/profile/']")?.getAttribute("href") || ""; const author = normalizeFeedText(authorNode?.textContent ?? "", 80); const authorId = readUserIdFromHref(authorHref); const topicIds = []; const topics = []; for (const node of Array.from(scope.querySelectorAll(".link-tags__tag-item"))) { const name = normalizeFeedText(node.textContent, 80); if (name !== "" && !topics.includes(name)) topics.push(name); } for (const anchor of Array.from(scope.querySelectorAll("a[href*='/app/topic/link/']"))) { const matched = /topic\/link\/(\d+)/.exec(anchor.getAttribute("href") || ""); if (matched) { const topicId = normalizeFeedId(matched[1]); if (topicId !== "" && !topicIds.includes(topicId)) topicIds.push(topicId); } const name = normalizeFeedText(anchor.textContent, 80); if (name !== "" && !topics.includes(name)) topics.push(name); } const heading = normalizeFeedText(title, FEED_TITLE_MAX) || normalizeFeedText(scope.querySelector(READER_SOURCE_TITLE_SELECTOR)?.textContent ?? "", FEED_TITLE_MAX); return { title: heading, authorId, author, topicIds, topics }; } function readPostTrail() { return readSessionArray(POST_TRAIL_KEY).filter((id) => typeof id === "string" && /^\d+$/.test(id)); } function readVisitedPostIds(currentId) { const visited = new Set(readPostTrail()); if (currentId !== "") visited.add(currentId); return visited; } function pushPostTrail(id) { const trail = readPostTrail(); if (trail[trail.length - 1] === id) return; const seenAt = trail.lastIndexOf(id); if (seenAt !== -1) trail.splice(seenAt, 1); trail.push(id); while (trail.length > POST_TRAIL_MAX) trail.shift(); writeSessionValue(POST_TRAIL_KEY, trail); } function readFeedQueue(accountId = getHeyboxId()) { return mergeFeedQueue(readSessionArray(buildFeedStateKey(FEED_QUEUE_KEY, accountId)), []); } function writeFeedQueue(items, accountId = getHeyboxId()) { return writeSessionValue(buildFeedStateKey(FEED_QUEUE_KEY, accountId), mergeFeedQueue(items, [], { max: FEED_QUEUE_MAX })); } function readFeedCursorState(accountId = getHeyboxId()) { const raw = sessionStore.read(buildFeedStateKey(FEED_CURSOR_KEY, accountId)); if (raw === null || raw === "") return { present: false, offset: 0 }; const value = Number(raw); return { present: true, offset: Number.isSafeInteger(value) && value >= 0 ? value : 0 }; } function writeFeedCursor(offset, accountId = getHeyboxId()) { if (!Number.isSafeInteger(offset) || offset < 0) return false; return sessionStore.write(buildFeedStateKey(FEED_CURSOR_KEY, accountId), String(offset)); } function readFeedSeen(accountId = getHeyboxId()) { return mergeFeedSeen(readSessionArray(buildFeedStateKey(FEED_SEEN_KEY, accountId)), []); } function writeFeedSeen(ids, accountId = getHeyboxId()) { const list = mergeFeedSeen(ids instanceof Set ? Array.from(ids) : ids, []); writeSessionValue(buildFeedStateKey(FEED_SEEN_KEY, accountId), list); return list; } function readFeedRecent(accountId = getHeyboxId()) { return mergeRecentMetadata(readSessionArray(buildFeedStateKey(FEED_RECENT_KEY, accountId)), null); } function writeFeedRecent(recent, accountId = getHeyboxId()) { writeSessionValue(buildFeedStateKey(FEED_RECENT_KEY, accountId), mergeRecentMetadata(recent, null)); } function readFeedRecentSignature(recent) { return recent.map((item) => [item.id, item.title, item.authorId, item.author, item.topicIds.join(","), item.topics.join(",")].join("~")).join("|"); } function createPostNavigationController() { let currentId = ""; let related = []; let accountId = getHeyboxId(); let queue = []; let cursor = 0; let cursorStored = false; let seen = /* @__PURE__ */ new Set(); let recent = []; let recentKey = ""; let discovering = false; let discoveryError = ""; let exhausted = false; let generation = 0; let revision = 0; const listeners = /* @__PURE__ */ new Set(); let statusKey = ""; const loadAccountState = () => { queue = readFeedQueue(accountId); const storedCursor = readFeedCursorState(accountId); cursor = storedCursor.present ? storedCursor.offset : computeFeedDiscoveryOffset(readFeedOrder(), FEED_DISCOVERY_PAGE_SIZE); cursorStored = storedCursor.present; seen = new Set(readFeedSeen(accountId)); recent = readFeedRecent(accountId); recentKey = readFeedRecentSignature(recent); }; const syncAccount = () => { const nextAccountId = getHeyboxId(); if (nextAccountId === accountId) return false; accountId = nextAccountId; generation += 1; discovering = false; discoveryError = ""; exhausted = false; loadAccountState(); return true; }; loadAccountState(); const readStatus = () => { const trail = readPostTrail(); let prev = null; if (currentId !== "") { const at = trail.lastIndexOf(currentId); const prevId = at > 0 ? trail[at - 1] : at === -1 && trail.length > 0 ? trail[trail.length - 1] : ""; if (prevId !== "" && prevId !== currentId) prev = { id: prevId, title: "" }; } let next = null; if (currentId !== "") { const picked = selectNextFeedCandidate({ currentId, visited: readVisitedPostIds(currentId), seen, recent, feedOrder: readFeedOrder(), queue, related }); if (picked !== null) next = { id: picked.candidate.id, title: picked.candidate.title, source: picked.source }; } return { currentId, revision, prev, next, discovering, error: discoveryError, exhausted }; }; const publish = (force) => { const status = readStatus(); const key = [ status.currentId, String(status.revision), status.prev === null ? "" : status.prev.id, status.next === null ? "" : status.next.id, status.discovering ? "1" : "0", status.error, status.exhausted ? "1" : "0" ].join("|"); if (force !== true && key === statusKey) return; statusKey = key; for (const listener of Array.from(listeners)) { try { listener(status); } catch { } } }; const sync = (id, meta, links) => { syncAccount(); const nextId = normalizeFeedId(id); const nextRelated = normalizeFeedDiscoveryLinks(links); related = nextRelated === null ? [] : nextRelated; if (nextId === "") { if (currentId !== "") { currentId = ""; revision += 1; discoveryError = ""; exhausted = false; publish(true); } return; } const changed = nextId !== currentId; currentId = nextId; if (changed) { revision += 1; pushPostTrail(nextId); discoveryError = ""; exhausted = false; if (queue.some((item) => item.id === nextId)) { queue = queue.filter((item) => item.id !== nextId); writeFeedQueue(queue, accountId); } } if (!seen.has(nextId)) { const nextSeen = new Set(seen); nextSeen.add(nextId); seen = new Set(writeFeedSeen(nextSeen, accountId)); } const incoming = normalizeFeedCandidate(meta !== null && typeof meta === "object" ? { ...meta, id: nextId } : { id: nextId }); if (incoming !== null) { const merged = mergeRecentMetadata(recent, incoming); const key = readFeedRecentSignature(merged); if (key !== recentKey) { recent = merged; recentKey = key; writeFeedRecent(recent, accountId); } } publish(changed); }; const takeNext = () => { const status = readStatus(); if (status.next === null) return null; return { id: status.next.id, title: status.next.title }; }; const discover = async () => { syncAccount(); if (discovering || currentId === "") return; if (!cursorStored) { cursor = computeFeedDiscoveryOffset(readFeedOrder(), FEED_DISCOVERY_PAGE_SIZE); cursorStored = writeFeedCursor(cursor, accountId); } discovering = true; discoveryError = ""; exhausted = false; generation += 1; const runGeneration = generation; const controller = new AbortController(); publish(true); try { for (let pass = 0; pass < FEED_DISCOVERY_PASS_PAGES; pass += 1) { const page = await requestFeedDiscoveryPage(cursor, controller.signal); if (runGeneration !== generation) return; if (!page.ok) { discoveryError = page.message; break; } const nextQueue = mergeFeedQueue(queue, page.links, { currentId, visited: readVisitedPostIds(currentId), seen, max: FEED_QUEUE_MAX }); if (!writeFeedQueue(nextQueue, accountId)) { if (runGeneration !== generation) return; discoveryError = "无法保存连读进度;点击“下一篇”重试"; break; } queue = nextQueue; const nextCursor = cursor + FEED_DISCOVERY_PAGE_SIZE; if (writeFeedCursor(nextCursor, accountId)) { cursor = nextCursor; cursorStored = true; } if (page.rawEmpty === true) break; if (hasDiscoverableNext(readStatus())) break; } } catch (error) { if (runGeneration !== generation) return; if (!isAbortError(error)) discoveryError = "信息流请求失败,请稍后重试"; } finally { if (runGeneration === generation) { discovering = false; if (discoveryError === "") exhausted = readStatus().next === null; publish(true); } } }; const subscribe = (listener) => { if (typeof listener !== "function") return () => { }; const callback = listener; listeners.add(callback); return () => { listeners.delete(callback); }; }; return { sync, readStatus, takeNext, discover, subscribe }; } function isTypingTarget(target) { if (!(target instanceof Element)) return false; if (target instanceof HTMLElement && target.isContentEditable) return true; return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT"; } function isSiteAlertOpen() { return document.querySelector(".alert-container-wrapper .hb-cpt__alert-wrapper") !== null; } function eventHitsTypingTarget(event) { for (const node of event.composedPath()) { if (isTypingTarget(node)) return true; } return isTypingTarget(event.target); } function isFollowedAuthor(button) { if (!(button instanceof Element)) return false; if (button.classList.contains("followed")) return true; if (button.getAttribute("aria-pressed") === "true") return true; return /^(已关注|互相关注)$/.test((button.textContent || "").trim()); } function watchPostPager(navigation) { if (!document.body) return; const host = document.createElement("div"); host.id = PAGER_ID; host.hidden = true; const shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = PAGER_CSS; const pager = document.createElement("nav"); pager.className = "pager"; pager.setAttribute("aria-label", "文章连读"); const prevButton = document.createElement("button"); prevButton.type = "button"; prevButton.className = "btn btn--prev"; prevButton.setAttribute("aria-label", "上一篇"); prevButton.title = "上一篇"; prevButton.innerHTML = PAGER_ICONS.prev; const nextButton = document.createElement("button"); nextButton.type = "button"; nextButton.className = "btn btn--next"; nextButton.setAttribute("aria-label", "下一篇"); nextButton.title = "下一篇"; nextButton.innerHTML = PAGER_ICONS.next; const statusNode = document.createElement("p"); statusNode.className = "pager-status"; statusNode.setAttribute("role", "status"); statusNode.setAttribute("aria-live", "polite"); statusNode.hidden = true; pager.append(prevButton, nextButton, statusNode); shadow.append(style, pager); let currentId = ""; let prevId = ""; let navigating = false; let pendingAdvanceSourceId = ""; let pendingAdvanceRevision = -1; const clearPendingAdvance = () => { pendingAdvanceSourceId = ""; pendingAdvanceRevision = -1; }; const go = (id) => { if (id === "" || navigating) return; navigating = true; void navigateToPath(LINK_HREF_PREFIX + id).then((accepted) => { if (accepted !== true) navigating = false; }, () => { navigating = false; }); const toTop = () => window.scrollTo({ top: 0, behavior: "auto" }); requestAnimationFrame(toTop); window.setTimeout(toTop, 120); }; const advance = () => { if (navigating) return; const status = navigation.readStatus(); if (status.next !== null && status.next.source !== "related") { const taken = navigation.takeNext(); if (taken !== null) { clearPendingAdvance(); go(taken.id); return; } } pendingAdvanceSourceId = status.currentId; pendingAdvanceRevision = status.revision; void navigation.discover(); }; const renderStatus = (status) => { prevId = status.prev === null ? "" : status.prev.id; prevButton.disabled = prevId === ""; prevButton.title = status.prev === null ? "没有上一篇" : "上一篇([)"; prevButton.setAttribute("aria-label", prevButton.title); nextButton.disabled = status.discovering; if (status.discovering) { nextButton.title = "正在寻找下一篇…"; } else if (status.next !== null) { nextButton.title = status.next.title === "" ? "下一篇(])" : "下一篇(]):" + status.next.title; } else if (status.error !== "") { nextButton.title = "没有找到下一篇:" + status.error + "(点击重试)"; } else if (status.exhausted) { nextButton.title = "已到信息流末尾(点击重试)"; } else { nextButton.title = "点击寻找下一篇(])"; } nextButton.setAttribute("aria-label", nextButton.title); statusNode.textContent = status.discovering ? "正在寻找下一篇…" : ""; statusNode.hidden = !status.discovering; }; prevButton.addEventListener("click", () => go(prevId)); nextButton.addEventListener("click", advance); navigation.subscribe((status) => { if (host.hidden) return; renderStatus(status); if (pendingAdvanceSourceId === "") return; if (status.currentId !== pendingAdvanceSourceId || status.revision !== pendingAdvanceRevision) { clearPendingAdvance(); return; } if (status.discovering) return; const taken = navigation.takeNext(); clearPendingAdvance(); if (taken !== null) go(taken.id); }); document.addEventListener("keydown", (event) => { if (host.hidden || document.body.classList.contains(READER_CLASS) || event.defaultPrevented) return; if (event.ctrlKey || event.metaKey || event.altKey) return; if (isSiteAlertOpen() || eventHitsTypingTarget(event)) return; if (event.key === "[") { const prev = navigation.readStatus().prev; if (prev === null) return; event.preventDefault(); go(prev.id); } else if (event.key === "]") { event.preventDefault(); advance(); } }); const sync = () => { const id = readPostLinkId(); if (id === "") { host.hidden = true; host.remove(); currentId = ""; navigating = false; clearPendingAdvance(); navigation.sync("", null, []); return; } if (host.parentNode !== document.body) document.body.append(host); if (id !== currentId) { currentId = id; navigating = false; host.hidden = false; } const pageRoot = document.querySelector("#page-bbs-link"); navigation.sync(id, pageRoot === null ? null : readPostMeta(pageRoot, ""), readRelatedPostLinks()); renderStatus(navigation.readStatus()); }; registerPageSync(sync, 200, [PAGE_ROUTE.ARTICLE], "article"); registerPageSync(recordFeedOrder, 1e3, [PAGE_ROUTE.FEED], "feed"); sync(); recordFeedOrder(); } function readReaderScroll(id) { if (id === "") return 0; const value = Number(sessionStore.read(READER_SCROLL_KEY + id)); return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; } function writeReaderScroll(id, index) { if (id === "") return; sessionStore.write(READER_SCROLL_KEY + id, String(index)); } function readReaderRatio(id) { if (id === "") return -1; const stored = sessionStore.read(READER_RATIO_KEY + id); if (stored === null) return -1; const value = Number(stored); return Number.isFinite(value) && value >= 0 && value <= 1 ? value : -1; } function writeReaderRatio(id, ratio) { if (id === "") return; sessionStore.write(READER_RATIO_KEY + id, ratio.toFixed(4)); } function ensureReaderStyle() { ensureStyleElement(READER_STYLE_ID, READER_CSS); } function watchReader(navigation) { if (!document.body) return; let host = null; let lightbox = null; let favourPanel = null; let favourButton = null; let spacingPanel = null; let spacingButton = null; let spacingInput = null; let articleId = ""; let dismissedArticleId = ""; let signature = ""; let activeIndex = 0; let pageCount = 0; let toastTimer = 0; let coverIndex = 0; let coverTotal = 0; let observer = null; let scrollRaf = 0; let restoring = false; let lastActionSyncAt = 0; let progressScroll = null; let progressCurrent = null; let progressFill = null; let lastProgressPercent = -1; let ratioSaveTimer = 0; let pendingRatio = null; let sourceCache = null; const suspendedEmbeds = /* @__PURE__ */ new Map(); let nativeActionArticle = null; let nativeActionBox = null; let nativeFavourButton = null; let linkStateController = null; let commentLoadController = null; let favourLoadController = null; let readerGeneration = 0; let linkState = null; let linkStatePromise = null; let cyPublishedCount = 0; let articleCommentPublishedCount = 0; let actionBusy = false; let favourMenuBusy = false; let favourSaveBusy = false; let favourCreateBusy = false; let favourCreateController = null; let favourSaveController = null; let favourPanelFolders = []; let favourPanelChecked = /* @__PURE__ */ new Set(); let favourPanelMembers = /* @__PURE__ */ new Set(); let favourPanelUnknown = /* @__PURE__ */ new Set(); let favourPanelFavourited = false; let favourPanelStatus = null; let favourPanelList = null; let favourPanelSave = null; let favourPanelCreateInput = null; let favourPanelCreateButton = null; let favourPanelClose = null; let cyDialog = null; let cyTextarea = null; let cySubmit = null; let cyStatus = null; let cyBusy = false; let articleCommentBusy = false; let pendingReplySeq = 0; const ARTICLE_COMMENT_MAX_LENGTH = 1e3; const articleComposerEmojiClosers = /* @__PURE__ */ new WeakMap(); let commentPanel = null; let commentPanelList = null; let commentPanelStatus = null; let commentPanelStatusText = null; let commentPanelRetry = null; let commentPanelMeta = null; let commentPanelMore = null; let commentPanelClose = null; let commentPanelRefresh = null; let articleReplyTarget = null; let commentState = createCommentState(); const COMMENT_PANEL_STALE_MS = 60 * 1e3; let readerAccountId = ""; const commentDeleteBusy = /* @__PURE__ */ new Set(); const collapsedCommentThreads = /* @__PURE__ */ new Set(); let readerPendingAdvanceSourceId = ""; let readerPendingAdvanceRevision = -1; let readerAdvanceLocked = false; let readerAdvanceLockToken = 0; let readerPendingCard = null; let readerPendingTimer = 0; let readerPendingArticleId = ""; let readerPendingGeneration = -1; function createCommentState() { return { items: [], page: 0, total: 0, loading: false, error: "", hasMore: false, loadedGroups: 0, loaded: false, loadedAt: 0, seq: 0, floorPages: /* @__PURE__ */ new Map(), pendingReplies: [], pendingBaselines: /* @__PURE__ */ new Map(), settledReplies: /* @__PURE__ */ new Set() }; } const showToast = (message) => { let toast = host?.querySelector(".hbu-reader-toast") ?? null; if (toast === null) { const created = document.createElement("div"); created.className = "hbu-reader-toast hbu-reader-toast--fixed"; created.setAttribute("aria-live", "polite"); document.body.append(created); toast = created; } const toastNode = toast; toastNode.textContent = message; toastNode.dataset["show"] = "true"; window.clearTimeout(toastTimer); toastTimer = window.setTimeout(() => { toastNode.dataset["show"] = "false"; if (toastNode.parentElement === document.body) window.setTimeout(() => toastNode.remove(), 220); }, 1400); }; const copyText = async (text) => { const active = document.activeElement instanceof HTMLElement ? document.activeElement : null; try { if (navigator.clipboard !== void 0 && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch (error) { } const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.top = "0"; area.style.left = "0"; area.style.width = "1px"; area.style.height = "1px"; area.style.padding = "0"; area.style.border = "0"; area.style.opacity = "0"; area.style.pointerEvents = "none"; document.body.append(area); area.select(); area.setSelectionRange(0, area.value.length); let copied = false; try { copied = document.execCommand("copy"); } catch (error) { copied = false; } area.remove(); active?.focus({ preventScroll: true }); return copied; }; const shareArticle = async (data) => { const url = "https://www.xiaoheihe.cn/app/bbs/link/" + articleId; const title = data.title || document.title || "小黑盒帖子"; if (typeof navigator.share === "function") { const payload = { title, text: title, url }; const shareable = typeof navigator.canShare !== "function" || navigator.canShare(payload); if (shareable) { try { await navigator.share(payload); return; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; } } } const copied = await copyText(url); showToast(copied ? "链接已复制" : "复制失败,请手动复制链接"); }; const articleActionBox = () => { if (nativeActionBox !== null && nativeActionBox.isConnected) return nativeActionBox; const article = sourceCache?.article?.isConnected === true ? sourceCache.article : document.querySelector("#page-bbs-link " + READER_SOURCE_ROOT_SELECTOR); if (article !== nativeActionArticle) { nativeActionArticle = article; nativeActionBox = null; nativeFavourButton = null; } const actionScope = article?.closest("#page-bbs-link") || document.querySelector("#page-bbs-link"); nativeActionBox = Array.from(actionScope?.querySelectorAll(".link-reply__operation-box") || []).find((box) => box.closest("[data-comment-id]") === null) || null; return nativeActionBox; }; const nativeLike = () => articleActionBox()?.querySelector("button:first-child") || null; const nativeFavour = () => { if (nativeFavourButton !== null && nativeFavourButton.isConnected) return nativeFavourButton; const scoped = articleActionBox()?.querySelector(".hb-cpt__favour-btn") || null; nativeFavourButton = scoped instanceof HTMLButtonElement ? scoped : Array.from(document.querySelectorAll("#page-bbs-link .hb-cpt__favour-btn")).find((node) => node.closest("[data-comment-id]") === null) || null; return nativeFavourButton; }; const readCounts = () => { const values = Array.from(articleActionBox()?.querySelectorAll(".link-reply__operation-desc") || []).map((node) => (node.textContent || "").trim()); return { like: values[0] || "0", favour: values[1] || "0", comment: values[2] || "0" }; }; const formatActionCount = (value) => { const count = Math.max(0, Number(value) || 0); if (count >= 1e8) return (count / 1e8).toFixed(1).replace(/\.0$/, "") + "亿"; if (count >= 1e4) return (count / 1e4).toFixed(1).replace(/\.0$/, "") + "万"; return String(count); }; const postReaderApi = async (path, body, accountId, signal) => { if (typeof accountId !== "string" || accountId === "") throw new Error("请先登录小黑盒"); const params = createApiParams(path); if (path === COMMENT_CREATE_API_PATH) { params["_rnd"] = await createCommentRnd(params.nonce, params._time); } const query = new URLSearchParams({ ...params, heybox_id: accountId, target: "heybox_app" }); const response = await fetchWithTimeout("https://api.xiaoheihe.cn" + path + "?" + query.toString(), { method: "POST", credentials: "include", cache: "no-store", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded;charset=UTF-8" }, body: new URLSearchParams(body).toString(), ...signal !== void 0 ? { signal } : {} }); if (!response.ok) throw new Error("接口返回 HTTP " + response.status); const payload = await response.json(); const data = asRecord(payload) ?? {}; if (data["status"] === "login") throw new Error("请先登录小黑盒"); if (data["status"] !== "ok") throw new Error(String(data["msg"] || "操作失败")); return data["result"]; }; const fetchFavourFolders = async (signal, accountId) => { if (typeof accountId !== "string" || accountId === "") throw new Error("请先登录小黑盒"); const path = "/bbs/app/profile/fav/folders"; const query = new URLSearchParams({ ...createApiParams(path), heybox_id: accountId, target: "heybox_app" }); const response = await fetchWithTimeout("https://api.xiaoheihe.cn" + path + "?" + query.toString(), { credentials: "include", cache: "no-store", headers: { accept: "application/json" }, signal }); if (!response.ok) throw new Error("接口返回 HTTP " + response.status); const payload = await response.json(); const data = asRecord(payload) ?? {}; if (data["status"] === "login") throw new Error("请先登录小黑盒"); if (data["status"] !== "ok") throw new Error(String(data["msg"] || "获取收藏夹失败")); const result = asRecord(data["result"]); const folders = result !== null && Array.isArray(result["folders"]) ? result["folders"] : []; return normalizeFavourFolders(folders); }; const FAVOUR_SCAN_PAGE_SIZE = 30; const FAVOUR_SCAN_MAX_PAGES = 10; const FAVOUR_SCAN_MAX_REQUESTS = 48; const fetchFavourFolderLinks = async (folderId, offset, accountId, signal) => { const path = "/bbs/app/profile/fav/folder/v2/links"; const query = new URLSearchParams({ ...createApiParams(path), heybox_id: accountId, target: "heybox_app", enable_new_style_collect: "1", folder_id: folderId, offset: String(offset), limit: String(FAVOUR_SCAN_PAGE_SIZE), dw: "660" }); const response = await fetchWithTimeout("https://api.xiaoheihe.cn" + path + "?" + query.toString(), { credentials: "include", cache: "no-store", headers: { accept: "application/json" }, signal }); if (!response.ok) throw new Error("接口返回 HTTP " + response.status); const payload = await response.json(); const data = asRecord(payload) ?? {}; if (data["status"] === "login") throw new Error("请先登录小黑盒"); if (data["status"] !== "ok") throw new Error(String(data["msg"] || "读取收藏夹失败")); return readFavourFolderPage(data["result"]); }; const scanFavourFolder = async (folderId, accountId, signal, budget) => { const ids = /* @__PURE__ */ new Set(); let offset = 0; for (let page = 0; page < FAVOUR_SCAN_MAX_PAGES; page += 1) { if (budget.left <= 0) return { ids, complete: false }; budget.left -= 1; const result = await fetchFavourFolderLinks(folderId, offset, accountId, signal); for (const id of result.linkIds) ids.add(id); if (result.linkIds.length < FAVOUR_SCAN_PAGE_SIZE) return { ids, complete: true }; offset += FAVOUR_SCAN_PAGE_SIZE; } return { ids, complete: false }; }; const scanFavourMemberships = async (folders, accountId, signal) => { const scans = /* @__PURE__ */ new Map(); const budget = { left: FAVOUR_SCAN_MAX_REQUESTS }; for (const folder of folders) { try { scans.set(folder.id, await scanFavourFolder(folder.id, accountId, signal, budget)); } catch (error) { if (isAbortError(error)) throw error; scans.set(folder.id, { ids: /* @__PURE__ */ new Set(), complete: false }); } } const signatures = /* @__PURE__ */ new Set(); let completeScans = 0; for (const folder of folders) { const scan = scans.get(folder.id); if (scan === void 0 || !scan.complete) continue; completeScans += 1; signatures.add(Array.from(scan.ids).sort().join(",")); } const filterSuspicious = folders.length >= 2 && completeScans >= 2 && signatures.size === 1; for (const folder of folders) { const scan = scans.get(folder.id); if (scan === void 0 || !scan.complete || filterSuspicious) { favourPanelUnknown.add(folder.id); continue; } if (scan.ids.has(articleId)) favourPanelMembers.add(folder.id); } }; const addPublishedActions = (link, publishedCy, publishedComments) => { if (publishedCy > 0) { link.commentCount = (Number(link.commentCount) || 0) + publishedCy; link.cyCount = Math.max(0, Number(link.cyCount) || 0) + publishedCy; } if (publishedComments > 0) { link.commentCount = (Number(link.commentCount) || 0) + publishedComments; } return link; }; const loadLinkState = async (accountId = "") => { if (articleId === "") return null; if (linkStatePromise !== null) return linkStatePromise; const targetId = articleId; const resolved = typeof accountId === "string" ? accountId : ""; const controller = new AbortController(); linkStateController = controller; const publishedAtStart = cyPublishedCount; const commentsAtStart = articleCommentPublishedCount; linkStatePromise = (async () => { try { const page = await fetchRailPage(targetId, 1, { signal: controller.signal, accountId: resolved }); if (articleId !== targetId || linkStateController !== controller) return null; const publishedDuringRequest = cyPublishedCount - publishedAtStart; const commentsDuringRequest = articleCommentPublishedCount - commentsAtStart; linkState = addPublishedActions(page.link, publishedDuringRequest, commentsDuringRequest); if (publishedDuringRequest > 0 || commentsDuringRequest > 0) clearRailPageCache(targetId); syncActionStates(); return linkState; } catch (error) { return null; } finally { if (linkStateController === controller) { linkStateController = null; linkStatePromise = null; } } })(); return linkStatePromise; }; const toggleLike = async () => { if (actionBusy || articleId === "") return; actionBusy = true; const targetId = articleId; const generation = readerGeneration; try { const accountId = await resolveHeyboxId(); if (articleId !== targetId || readerGeneration !== generation) return; if (accountId === "") throw new Error("请先登录小黑盒"); const counts = readCounts(); const current = linkState === null ? await loadLinkState(accountId) : linkState; if (articleId !== targetId || readerGeneration !== generation) return; const on = current !== null && current.isAward; const base = current === null ? parseCompactCount(counts.like) : current.awardCount; await postReaderApi("/bbs/app/profile/award/link", { link_id: targetId, award_type: on ? "0" : "1" }, accountId); if (articleId !== targetId || readerGeneration !== generation) return; linkState = { ...current || { isAward: false, isFavour: false, awardCount: base, favourCount: parseCompactCount(counts.favour), commentCount: parseCompactCount(counts.comment), cyCount: cyPublishedCount }, isAward: !on, awardCount: Math.max(0, base + (on ? -1 : 1)) }; syncActionStates(); showToast(on ? "已取消点赞" : "已点赞"); } catch (error) { if (articleId === targetId && readerGeneration === generation) { showToast(error instanceof Error ? error.message : "点赞失败"); } } finally { if (articleId === targetId && readerGeneration === generation) { actionBusy = false; } } }; const currentFavourPlan = () => planFavourChanges({ favourited: favourPanelFavourited, folderIds: favourPanelFolders.map((folder) => folder.id), checkedIds: Array.from(favourPanelChecked), memberIds: Array.from(favourPanelMembers), unknownIds: Array.from(favourPanelUnknown) }); const applyFavourState = (next) => { if (linkState === null || linkState.isFavour === next) return; linkState = { ...linkState, isFavour: next, favourCount: Math.max(0, linkState.favourCount + (next ? 1 : -1)) }; syncActionStates(); }; const setFavourStatus = (message, error = false) => { if (favourPanelStatus === null) return; favourPanelStatus.textContent = message; if (error) favourPanelStatus.dataset["tone"] = "error"; else delete favourPanelStatus.dataset["tone"]; }; const refreshFavourControls = () => { if (favourPanel === null) return; const busy = favourLoadController !== null || favourSaveBusy || favourCreateBusy; for (const control of favourPanel.querySelectorAll("input, button")) { control.disabled = busy; } if (favourPanelSave !== null) { const plan = currentFavourPlan(); favourPanelSave.disabled = busy || !planHasFavourChange(plan); } if (favourPanelClose !== null) favourPanelClose.disabled = favourSaveBusy; }; const syncActionStates = () => { if (host === null) return; const like = nativeLike(); const favour = nativeFavour(); const counts = linkState === null ? readCounts() : { like: formatActionCount(linkState.awardCount), favour: formatActionCount(linkState.favourCount), comment: formatActionCount(linkState.commentCount) }; const likeNode = host.querySelector('[data-action="like"]'); const favourNode = host.querySelector('[data-action="favour"]'); const commentNode = host.querySelector('[data-action="comment"]'); if (likeNode !== null) { const on2 = linkState === null ? like !== null && (like.getAttribute("aria-pressed") === "true" || like.classList.contains("active")) : linkState.isAward; likeNode.setAttribute("aria-pressed", String(on2)); const markup = (on2 ? READER_ICONS.likeOn : READER_ICONS.like) + '' + counts.like + ""; if (likeNode.innerHTML !== markup) likeNode.innerHTML = markup; } if (favourNode !== null) { const on2 = linkState === null ? favour !== null && (favour.getAttribute("aria-pressed") === "true" || favour.classList.contains("active")) : linkState.isFavour; favourNode.setAttribute("aria-pressed", String(on2)); const markup = (on2 ? READER_ICONS.starOn : READER_ICONS.star) + '' + counts.favour + ""; if (favourNode.innerHTML !== markup) favourNode.innerHTML = markup; } if (commentNode !== null) { const markup = READER_ICONS.comment + '' + counts.comment + ""; if (commentNode.innerHTML !== markup) commentNode.innerHTML = markup; } const cyNode = host.querySelector('[data-action="cy"]'); if (cyNode !== null) { const cyTotal = linkState === null ? cyPublishedCount : Math.max(0, Number(linkState.cyCount) || 0); const label = "CY " + formatActionCount(cyTotal); const markup = 'CY' + formatActionCount(cyTotal) + ""; if (cyNode.innerHTML !== markup) cyNode.innerHTML = markup; if (cyNode.title !== label) cyNode.title = label; if (cyNode.getAttribute("aria-label") !== label) cyNode.setAttribute("aria-label", label); } for (const stat of host.querySelectorAll("[data-stat]")) { const valueNode = stat.querySelector("b"); const key = stat.dataset["stat"]; if (key !== "like" && key !== "favour" && key !== "comment") continue; if (valueNode !== null && valueNode.textContent !== counts[key]) { valueNode.textContent = counts[key]; } } const nativeFollowButton = document.querySelector("#page-bbs-link .link-user__follow-btn"); const on = isFollowedAuthor(nativeFollowButton); for (const followNode of host.querySelectorAll(".hbu-reader-follow")) { followNode.setAttribute("aria-pressed", String(on)); if (followNode.textContent !== (on ? "已关注" : "关注")) followNode.textContent = on ? "已关注" : "关注"; } }; const clickNative = (selector) => { const node = document.querySelector("#page-bbs-link " + selector); if (node instanceof HTMLElement) node.click(); }; const toggleFollowAuthor = () => { clickNative(".link-user__follow-btn"); window.setTimeout(syncActionStates, 400); window.setTimeout(syncActionStates, 1200); }; const closeFavourMenu = (focusButton = false, force = false) => { if ((favourSaveBusy || favourCreateBusy) && !force) return; const wasOpen = favourPanel !== null && !favourPanel.hidden; favourLoadController?.abort(); favourLoadController = null; if (favourPanel !== null) { favourPanel.hidden = true; favourButton?.setAttribute("aria-expanded", "false"); } favourPanelFolders = []; favourPanelChecked = /* @__PURE__ */ new Set(); favourPanelMembers = /* @__PURE__ */ new Set(); favourPanelUnknown = /* @__PURE__ */ new Set(); favourPanelFavourited = false; if (focusButton && wasOpen) favourButton?.focus({ preventScroll: true }); }; const resetFavourPanel = () => { favourLoadController?.abort(); favourLoadController = null; favourSaveController?.abort(); favourSaveController = null; favourCreateController?.abort(); favourCreateController = null; favourMenuBusy = false; favourSaveBusy = false; favourCreateBusy = false; favourPanelFolders = []; favourPanelChecked = /* @__PURE__ */ new Set(); favourPanelMembers = /* @__PURE__ */ new Set(); favourPanelUnknown = /* @__PURE__ */ new Set(); favourPanelFavourited = false; favourPanel = null; favourPanelStatus = null; favourPanelList = null; favourPanelSave = null; favourPanelCreateInput = null; favourPanelCreateButton = null; favourPanelClose = null; favourButton = null; }; document.addEventListener("pointerdown", (event) => { if (favourPanel === null || favourPanel.hidden) return; const target = event.target instanceof Node ? event.target : null; if (favourPanel.contains(target) || favourButton?.contains(target)) return; closeFavourMenu(); }, true); const closeSpacingMenu = () => { if (spacingPanel === null) return; spacingPanel.hidden = true; spacingButton?.setAttribute("aria-expanded", "false"); }; document.addEventListener("pointerdown", (event) => { if (spacingPanel === null || spacingPanel.hidden) return; const target = event.target instanceof Node ? event.target : null; if (spacingPanel.contains(target) || spacingButton?.contains(target)) return; closeSpacingMenu(); }, true); const commitSpacingInput = () => { if (spacingInput === null) return; const next = parseReaderLineHeightInput(spacingInput.value); const value = next === null ? readReaderLineHeight() : setReaderLineHeightPreference(next); spacingInput.value = String(value); syncNumberInput(readerLineHeightInput, value); }; const openSpacingMenu = (rail, button) => { if (spacingPanel !== null && !spacingPanel.hidden) { closeSpacingMenu(); button.focus({ preventScroll: true }); return; } closeFavourMenu(); if (spacingPanel === null) { const panel = document.createElement("div"); panel.className = "hbu-reader-spacing-menu"; panel.hidden = true; panel.setAttribute("role", "group"); panel.setAttribute("aria-label", "正文行距"); const label = document.createElement("div"); label.className = "hbu-reader-spacing-label"; label.textContent = "正文行距"; const row = document.createElement("div"); row.className = "hbu-reader-spacing-row"; const input = document.createElement("input"); input.type = "number"; input.min = String(READER_LINE_HEIGHT_MIN); input.max = String(READER_LINE_HEIGHT_MAX); input.step = String(READER_LINE_HEIGHT_STEP); input.inputMode = "decimal"; input.setAttribute("aria-label", "正文行距倍数"); input.title = "正文行距 " + READER_LINE_HEIGHT_MIN + " - " + READER_LINE_HEIGHT_MAX + " 倍"; const unit = document.createElement("span"); unit.className = "hbu-reader-spacing-unit"; unit.textContent = "倍"; const reset = document.createElement("button"); reset.type = "button"; reset.className = "hbu-reader-spacing-reset"; reset.textContent = "默认"; reset.title = "恢复默认行距 " + READER_LINE_HEIGHT_DEFAULT + " 倍"; reset.addEventListener("click", () => { const value = setReaderLineHeightPreference(READER_LINE_HEIGHT_DEFAULT); if (spacingInput !== null) spacingInput.value = String(value); syncNumberInput(readerLineHeightInput, value); }); row.append(input, unit, reset); panel.append(label, row); bindNumberInput(input, commitSpacingInput); spacingPanel = panel; spacingInput = input; } spacingButton = button; if (spacingPanel.parentNode !== rail) rail.append(spacingPanel); if (spacingInput === null) return; spacingInput.value = String(readReaderLineHeight()); spacingPanel.hidden = false; button.setAttribute("aria-expanded", "true"); spacingInput.focus({ preventScroll: true }); }; const ensureFavourPanel = (rail) => { if (favourPanel === null) { const panel = document.createElement("div"); panel.className = "hbu-reader-favour-menu"; panel.hidden = true; panel.setAttribute("role", "dialog"); panel.setAttribute("aria-label", "收藏到收藏夹"); const head = document.createElement("div"); head.className = "hbu-reader-favour-head"; const title = document.createElement("span"); title.textContent = "收藏到收藏夹"; const close = document.createElement("button"); close.type = "button"; close.className = "hbu-reader-favour-close"; close.setAttribute("aria-label", "关闭收藏夹面板"); close.textContent = "×"; close.addEventListener("click", () => closeFavourMenu(true)); head.append(title, close); const status = document.createElement("div"); status.className = "hbu-reader-favour-status"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); const list = document.createElement("div"); list.className = "hbu-reader-favour-list"; list.setAttribute("role", "group"); list.setAttribute("aria-label", "收藏夹"); const create = document.createElement("div"); create.className = "hbu-reader-favour-create"; const input = document.createElement("input"); input.type = "text"; input.className = "hbu-reader-favour-create-input"; input.maxLength = 30; input.placeholder = "新建收藏夹"; input.setAttribute("aria-label", "新收藏夹名称"); const createButton = document.createElement("button"); createButton.type = "button"; createButton.className = "hbu-reader-favour-create-btn"; createButton.textContent = "创建"; createButton.addEventListener("click", () => void createFavourFolder()); input.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); void createFavourFolder(); }); create.append(input, createButton); const actions = document.createElement("div"); actions.className = "hbu-reader-favour-actions"; const save = document.createElement("button"); save.type = "button"; save.className = "hbu-reader-favour-save"; save.textContent = "保存"; save.addEventListener("click", () => void saveFavourChanges()); actions.append(save); panel.append(head, status, list, create, actions); rail.append(panel); favourPanel = panel; favourPanelStatus = status; favourPanelList = list; favourPanelSave = save; favourPanelCreateInput = input; favourPanelCreateButton = createButton; favourPanelClose = close; } if (favourPanel.parentNode !== rail) rail.append(favourPanel); return favourPanel; }; const renderFavourFolderList = () => { if (favourPanelList === null) return; const list = favourPanelList; if (favourPanelFolders.length === 0) { const empty = document.createElement("div"); empty.className = "hbu-reader-favour-empty"; empty.textContent = "还没有收藏夹,可在下方新建"; list.replaceChildren(empty); refreshFavourControls(); return; } const rows = []; for (const folder of favourPanelFolders) { const option = document.createElement("label"); option.className = "hbu-reader-favour-option"; const input = document.createElement("input"); input.type = "checkbox"; input.value = folder.id; input.checked = favourPanelChecked.has(folder.id); input.addEventListener("change", () => { if (input.checked) favourPanelChecked.add(folder.id); else favourPanelChecked.delete(folder.id); refreshFavourControls(); }); const name = document.createElement("span"); name.className = "hbu-reader-favour-name"; name.textContent = folder.name; name.title = folder.name; option.append(input, name); if (favourPanelUnknown.has(folder.id)) { const tag = document.createElement("span"); tag.className = "hbu-reader-favour-tag"; tag.textContent = "待核对"; tag.title = "未能读取该收藏夹的收藏状态,取消勾选不会移除"; option.append(tag); } rows.push(option); } list.replaceChildren(...rows); refreshFavourControls(); }; const createFavourFolder = async () => { if (favourPanelCreateInput === null || favourPanel === null) return; if (favourCreateBusy || favourSaveBusy || favourLoadController !== null || articleId === "") return; const name = favourPanelCreateInput.value.trim(); if (name === "") { setFavourStatus("请输入收藏夹名称", true); favourPanelCreateInput.focus({ preventScroll: true }); return; } const targetId = articleId; const generation = readerGeneration; const previousIds = new Set(favourPanelFolders.map((folder) => folder.id)); favourCreateBusy = true; const controller = new AbortController(); favourCreateController = controller; refreshFavourControls(); setFavourStatus("正在创建收藏夹…"); let message = ""; let created = null; try { const accountId = await resolveHeyboxId(); if (accountId === "") throw new Error("请先登录小黑盒"); const result = await postReaderApi("/bbs/app/profile/fav/folder/add", { name }, accountId, controller.signal); if (articleId !== targetId || readerGeneration !== generation || favourCreateController !== controller) return; const createdId = readFavourFolderId(result); const loadController = new AbortController(); favourLoadController = loadController; let folders; try { folders = await fetchFavourFolders(loadController.signal, accountId); } catch (error) { if (isAbortError(error)) throw error; folders = favourPanelFolders.slice(); } finally { if (favourLoadController === loadController) favourLoadController = null; } if (articleId !== targetId || readerGeneration !== generation || favourCreateController !== controller) return; let id = createdId; if (id === "") { const fresh = folders.find((folder) => !previousIds.has(folder.id) && folder.name === name); if (fresh !== void 0) id = fresh.id; } favourPanelFolders = folders; if (id !== "") { created = { id, name }; if (!favourPanelFolders.some((folder) => folder.id === id)) { favourPanelFolders = favourPanelFolders.concat([created]); } favourPanelChecked.add(id); favourPanelMembers.delete(id); favourPanelUnknown.delete(id); message = "已创建收藏夹「" + name + "」,勾选后保存"; } else { message = "已创建收藏夹,请重新打开面板后勾选"; } if (favourPanelCreateInput !== null) favourPanelCreateInput.value = ""; } catch (error) { if (isAbortError(error)) return; setFavourStatus("创建收藏夹失败:" + (error instanceof Error ? error.message : "请稍后重试"), true); } finally { if (favourCreateController === controller) { favourCreateController = null; favourCreateBusy = false; refreshFavourControls(); } } if (articleId !== targetId || readerGeneration !== generation) return; renderFavourFolderList(); if (message !== "") setFavourStatus(message); }; const saveFavourChanges = async () => { if (favourSaveBusy || favourCreateBusy || favourMenuBusy || articleId === "") return; if (favourPanel === null || favourLoadController !== null) return; const plan = currentFavourPlan(); if (!favourPanelFavourited && favourPanelChecked.size === 0) { setFavourStatus("请至少选择一个收藏夹", true); return; } if (!planHasFavourChange(plan)) { setFavourStatus(favourPanelUnknown.size > 0 ? "没有可保存的变更,部分收藏夹未核对" : "没有需要保存的变更"); return; } const targetId = articleId; const generation = readerGeneration; favourSaveBusy = true; const controller = new AbortController(); favourSaveController = controller; refreshFavourControls(); setFavourStatus("正在保存…"); const appliedAdd = []; const appliedRemove = []; let removeAll = false; let failure = ""; try { const userId = await resolveHeyboxId(); if (userId === "") throw new Error("请先登录小黑盒"); for (const id of plan.addIds) { await postReaderApi("/bbs/app/link/favour", { favour_type: "1", link_id: targetId, userid: userId, folder_id: id }, userId, controller.signal); appliedAdd.push(id); } if (plan.removeAll) { await postReaderApi("/bbs/app/link/favour", { favour_type: "2", link_id: targetId, userid: userId, folder_id: "" }, userId, controller.signal); removeAll = true; } else { for (const id of plan.removeIds) { await postReaderApi("/bbs/app/link/favour", { favour_type: "2", link_id: targetId, userid: userId, folder_id: id }, userId, controller.signal); appliedRemove.push(id); } } } catch (error) { failure = error instanceof Error ? error.message : "收藏保存失败"; } finally { if (favourSaveController === controller) { favourSaveController = null; favourSaveBusy = false; } } if (articleId !== targetId || readerGeneration !== generation) return; const appliedPlan = { addIds: appliedAdd, removeIds: appliedRemove, removeAll, unknownIds: plan.unknownIds }; const nextFavourited = resolveFavourOutcome(favourPanelFavourited, Array.from(favourPanelMembers), appliedPlan); const wasFavourited = favourPanelFavourited; if (nextFavourited !== wasFavourited) applyFavourState(nextFavourited); if (failure !== "") { for (const id of appliedAdd) { favourPanelChecked.add(id); favourPanelMembers.add(id); favourPanelUnknown.delete(id); } for (const id of appliedRemove) { favourPanelChecked.delete(id); favourPanelMembers.delete(id); } favourPanelFavourited = nextFavourited; renderFavourFolderList(); setFavourStatus("保存失败:" + failure + "。已完成的修改已保留,可再次保存。", true); return; } closeFavourMenu(true); showToast(wasFavourited ? "已更新收藏夹" : "已收藏"); }; const showFavourPanel = async (rail, button, accountId) => { ensureFavourPanel(rail); if (favourPanel === null) return; favourButton = button; favourPanel.hidden = false; button.setAttribute("aria-expanded", "true"); favourPanelFolders = []; favourPanelChecked = /* @__PURE__ */ new Set(); favourPanelMembers = /* @__PURE__ */ new Set(); favourPanelUnknown = /* @__PURE__ */ new Set(); favourPanelFavourited = linkState !== null && linkState.isFavour; renderFavourFolderList(); setFavourStatus("正在读取收藏夹…"); favourLoadController?.abort(); const controller = new AbortController(); favourLoadController = controller; refreshFavourControls(); favourPanelClose?.focus({ preventScroll: true }); let folders; try { folders = await fetchFavourFolders(controller.signal, accountId); } catch (error) { if (isAbortError(error) || favourPanel === null || favourLoadController !== controller) return; favourLoadController = null; refreshFavourControls(); setFavourStatus(error instanceof Error ? error.message : "获取收藏夹失败", true); return; } if (favourPanel === null || favourLoadController !== controller) return; favourPanelFolders = folders; renderFavourFolderList(); if (folders.length === 0) { favourLoadController = null; refreshFavourControls(); setFavourStatus("还没有收藏夹,可在下方新建"); return; } if (!favourPanelFavourited) { favourLoadController = null; refreshFavourControls(); setFavourStatus("勾选要收藏到的收藏夹,然后保存"); return; } setFavourStatus("正在核对收藏夹…"); try { await scanFavourMemberships(folders, accountId, controller.signal); } catch (error) { if (!isAbortError(error)) { for (const folder of folders) favourPanelUnknown.add(folder.id); } } if (favourPanel === null || favourLoadController !== controller) return; favourLoadController = null; renderFavourFolderList(); setFavourStatus(favourPanelUnknown.size > 0 ? "部分收藏夹未能核对,未勾选的不会移除" : "勾选加入,取消勾选移除,然后保存"); }; const openFavourMenu = async (rail, button) => { if (favourMenuBusy || favourSaveBusy) return; if (favourPanel !== null && !favourPanel.hidden) { closeFavourMenu(true); return; } closeSpacingMenu(); const targetId = articleId; const generation = readerGeneration; favourMenuBusy = true; try { const accountId = await resolveHeyboxId(); if (articleId !== targetId || readerGeneration !== generation || !rail.isConnected || host === null) return; if (accountId === "") { showToast("请先登录小黑盒"); return; } if (linkState === null) await loadLinkState(accountId); if (articleId !== targetId || readerGeneration !== generation || !rail.isConnected || host === null) return; await showFavourPanel(rail, button, accountId); } finally { favourMenuBusy = false; } }; const closeCyDialog = () => { if (cyDialog === null) return; cyDialog.remove(); cyDialog = null; cyTextarea = null; cySubmit = null; cyStatus = null; if (host !== null && !isCommentPanelOpen()) { host.querySelector('[data-action="cy"]')?.focus({ preventScroll: true }); } }; const submitCy = async () => { if (cyBusy || cyTextarea === null || cySubmit === null || cyStatus === null) return; const text = cyTextarea.value.trim(); if (text === "") { cyStatus.textContent = "请输入 CY 内容"; cyTextarea.focus(); return; } cyBusy = true; cySubmit.disabled = true; cySubmit.textContent = "发布中…"; cyStatus.textContent = ""; const targetId = articleId; const generation = readerGeneration; try { const accountId = await resolveHeyboxId(); if (articleId !== targetId || readerGeneration !== generation) return; if (accountId === "") throw new Error("请先登录小黑盒"); await postReaderApi(COMMENT_CREATE_API_PATH, { is_cy: "1", link_id: targetId, reply_id: "-1", root_id: "-1", text }, accountId); if (articleId !== targetId || readerGeneration !== generation) return; cyPublishedCount += 1; holdRailPageCache(targetId); if (linkState !== null) { addPublishedActions(linkState, 1, 0); } else { void loadLinkState(accountId); } showToast("CY 已发布"); closeCyDialog(); syncActionStates(); } catch (error) { if (articleId === targetId && readerGeneration === generation && cyStatus !== null) { cyStatus.textContent = error instanceof Error ? error.message : "CY 发布失败"; } } finally { if (articleId === targetId && readerGeneration === generation) { cyBusy = false; if (cySubmit !== null) { cySubmit.disabled = false; cySubmit.textContent = "发布"; } } } }; const openCyDialog = () => { if (host === null) return; closeFavourMenu(); if (cyDialog !== null) { cyDialog.hidden = false; cyTextarea?.focus({ preventScroll: true }); return; } const dialog = document.createElement("div"); dialog.className = "hbu-reader-cy-dialog"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-label", "发布 CY"); const card = document.createElement("div"); card.className = "hbu-reader-cy-card"; const head = document.createElement("div"); head.className = "hbu-reader-cy-head"; const title = document.createElement("span"); title.textContent = "发布 CY"; const close = document.createElement("button"); close.type = "button"; close.className = "hbu-reader-cy-close"; close.setAttribute("aria-label", "关闭 CY 输入框"); close.innerHTML = READER_ICONS.close; close.addEventListener("click", closeCyDialog); head.append(title, close); const textarea = document.createElement("textarea"); textarea.className = "hbu-reader-cy-text"; textarea.maxLength = 1e3; textarea.placeholder = "写下这次插眼的内容…"; textarea.setAttribute("aria-label", "CY 内容"); const status = document.createElement("div"); status.className = "hbu-reader-cy-status"; status.setAttribute("role", "status"); const actions = document.createElement("div"); actions.className = "hbu-reader-cy-actions"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.textContent = "取消"; cancel.addEventListener("click", closeCyDialog); const submit = document.createElement("button"); submit.type = "button"; submit.dataset["primary"] = "true"; submit.textContent = "发布"; if (cyBusy) { submit.disabled = true; submit.textContent = "发布中…"; } submit.addEventListener("click", () => void submitCy()); actions.append(cancel, submit); card.append(head, textarea, status, actions); dialog.append(card); dialog.addEventListener("click", (event) => { if (event.target === dialog) closeCyDialog(); }); dialog.addEventListener("keydown", (event) => { if (event.key !== "Tab") return; const items = Array.from(dialog.querySelectorAll("button, textarea")).filter((node) => node.getBoundingClientRect().width > 0); if (items.length === 0) return; const first = items[0]; const last = items[items.length - 1]; if (first === void 0 || last === void 0) return; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus({ preventScroll: true }); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus({ preventScroll: true }); } }); cyDialog = dialog; cyTextarea = textarea; cySubmit = submit; cyStatus = status; host.append(dialog); textarea.focus({ preventScroll: true }); }; const fillReaderCommentBody = (node, segments) => { if (segments.length === 0) { node.textContent = "(图片或表情评论)"; return; } for (const segment of segments) { if (segment.type === "text") { node.append(document.createTextNode(segment.text)); continue; } if (segment.type === "emoji") { const emoji = document.createElement("span"); emoji.className = "hbu-reader-comments-emoji"; emoji.setAttribute("role", "img"); emoji.setAttribute("aria-label", "表情"); const column = Number(segment.id) % 10 - 1; const row = Math.floor(Number(segment.id) / 10) - 1; emoji.style.backgroundImage = 'url("' + RAIL_EMOJI_SPRITE + '")'; emoji.style.backgroundPosition = "-" + column * RAIL_EMOJI_CELL + "px -" + row * RAIL_EMOJI_CELL + "px"; node.append(emoji); continue; } const image = document.createElement("img"); image.alt = ""; image.loading = "lazy"; image.referrerPolicy = "no-referrer"; image.src = segment.src; node.append(image); } }; const createReaderCommentDeleteButton = (comment) => { if (comment.id === "" || isPendingReplyRow(comment.id)) return null; if (!isOwnRailComment(comment, readerAccountId)) return null; const remove = document.createElement("button"); remove.type = "button"; remove.className = "hbu-reader-comments-delete"; remove.textContent = commentDeleteBusy.has(comment.id) ? "删除中…" : "删除"; remove.setAttribute("aria-label", "删除这条评论"); remove.dataset["deleteId"] = comment.id; remove.disabled = commentDeleteBusy.has(comment.id); return remove; }; const readerCommentProfileHref = (userId) => "/app/user/profile/" + encodeURIComponent(userId); const createReaderCommentAvatar = (comment, small) => { const avatar = document.createElement("img"); avatar.className = "hbu-reader-comments-avatar"; avatar.alt = ""; avatar.loading = "lazy"; avatar.referrerPolicy = "no-referrer"; if (comment.avatar !== "") avatar.src = comment.avatar; const wrapperClass = "hbu-reader-comments-avatar-link" + (small ? " hbu-reader-comments-avatar-link--child" : ""); if (comment.userId === "") { const span = document.createElement("span"); span.className = wrapperClass; span.append(avatar); return span; } const link = document.createElement("a"); link.className = wrapperClass; link.href = readerCommentProfileHref(comment.userId); link.target = "_blank"; link.rel = "noopener noreferrer"; link.setAttribute("aria-label", "查看 " + comment.name + " 的主页"); link.title = "查看 " + comment.name + " 的主页"; link.append(avatar); return link; }; const createReaderCommentName = (comment) => { const name = document.createElement("span"); name.className = "hbu-reader-comments-name"; name.textContent = comment.name; if (comment.userId === "") return name; const link = document.createElement("a"); link.className = "hbu-reader-comments-name-link"; link.href = readerCommentProfileHref(comment.userId); link.target = "_blank"; link.rel = "noopener noreferrer"; link.setAttribute("aria-label", "查看 " + comment.name + " 的主页"); link.title = comment.name + " 的主页"; link.append(name); return link; }; const filterReaderCommentThread = (nodes, hideCy) => nodes.filter((node) => !hideCy || node.isCy !== true).map((node) => ({ ...node, children: filterReaderCommentThread(node.children, hideCy) })); const resolvePendingFloorId = (items, pending) => { if (pending.rootId !== "" && items.some((item) => item.id === pending.rootId)) return pending.rootId; return findRailFloorIdForComment(items, pending.parentId); }; const readPendingRepliesFor = (floorId) => commentState.pendingReplies.filter((pending) => resolvePendingFloorId(commentState.items, pending) === floorId); const withPendingReplies = (entry) => attachRailPendingReplies(entry, readPendingRepliesFor(entry.id)); const isPendingReplyRow = (commentId) => commentId.startsWith(RAIL_PENDING_ID_PREFIX) || commentState.pendingReplies.some((row) => row.id === commentId); const readPendingBaseline = (row) => commentState.pendingBaselines.get(row.id); const createThreadCollapseButton = (threadId, hiddenCount) => { const collapse = document.createElement("button"); collapse.type = "button"; collapse.className = "hbu-reader-comments-collapse"; collapse.dataset["threadId"] = threadId; collapse.textContent = "收起回复"; collapse.setAttribute("aria-expanded", "true"); collapse.setAttribute("aria-label", "收起这条评论下的 " + hiddenCount + " 条回复"); return collapse; }; const createCollapsedThreadRow = (threadId, hiddenCount) => { const holder = document.createElement("ul"); holder.className = "hbu-reader-comments-children hbu-reader-comments-children--collapsed"; const hiddenRow = document.createElement("li"); hiddenRow.className = "hbu-reader-comments-child hbu-reader-comments-child--collapsed"; const expand = document.createElement("button"); expand.type = "button"; expand.className = "hbu-reader-comments-expand"; expand.dataset["threadId"] = threadId; expand.textContent = hiddenCount + " 条回复已收起"; expand.setAttribute("aria-expanded", "false"); hiddenRow.append(expand); holder.append(hiddenRow); return holder; }; const focusCommentThreadControl = (selector, threadId) => { for (const control of commentPanelList?.querySelectorAll(selector) ?? []) { if (control.dataset["threadId"] !== threadId) continue; control.focus({ preventScroll: true }); return; } }; const createReaderCommentItem = (raw) => { const comment = withPendingReplies(raw); const item = document.createElement("li"); item.className = "hbu-reader-comments-item"; item.setAttribute("role", "listitem"); const avatar = createReaderCommentAvatar(comment, false); const main = document.createElement("div"); main.className = "hbu-reader-comments-main"; const who = document.createElement("div"); who.className = "hbu-reader-comments-who"; who.append(createReaderCommentName(comment)); if (comment.createAt > 0) { const time = document.createElement("span"); time.className = "hbu-reader-comments-time"; time.textContent = formatRailAge(comment.createAt); who.append(time); } const text = document.createElement("p"); text.className = "hbu-reader-comments-text"; fillReaderCommentBody(text, comment.body); main.append(who, text); const meta = document.createElement("div"); meta.className = "hbu-reader-comments-meta"; if (comment.likes > 0) { const likes = document.createElement("span"); likes.textContent = "赞 " + comment.likes; meta.append(likes); } if (comment.replies > 0) { const replies = document.createElement("span"); replies.textContent = comment.replies + " 条回复"; meta.append(replies); } meta.append(createReaderReplyButton({ id: comment.id, rootId: comment.rootId !== "" ? comment.rootId : comment.id, name: comment.name })); const deleteButton = createReaderCommentDeleteButton(comment); if (deleteButton !== null) meta.append(deleteButton); const visibleChildren = filterReaderCommentThread(buildRailCommentTree(comment), readHideCyPreference()); const hiddenCount = countRailThreadNodes(visibleChildren); const collapsible = comment.id !== "" && visibleChildren.length > 0; const collapsed = collapsible && collapsedCommentThreads.has(comment.id); if (collapsible && !collapsed) meta.append(createThreadCollapseButton(comment.id, hiddenCount)); main.append(meta); if (visibleChildren.length > 0) { if (collapsed) { main.append(createCollapsedThreadRow(comment.id, hiddenCount)); } else { const children = document.createElement("ul"); children.className = "hbu-reader-comments-children"; for (const child of visibleChildren) children.append(createReaderCommentChild(child, comment, 1)); main.append(children); } } item.append(avatar, main); return item; }; const createReaderCommentChild = (child, parent, depth) => { const item = document.createElement("li"); item.className = "hbu-reader-comments-child"; item.setAttribute("role", "listitem"); const avatar = createReaderCommentAvatar(child, true); const main = document.createElement("div"); main.className = "hbu-reader-comments-child-main"; const who = document.createElement("div"); who.className = "hbu-reader-comments-who"; who.append(createReaderCommentName(child)); const replyTo = child.replyName !== "" ? child.replyName : parent.name; if (replyTo !== "") { const hint = document.createElement("span"); hint.className = "hbu-reader-comments-reply-to"; hint.textContent = "回复 @" + replyTo; who.append(hint); } if (child.createAt > 0) { const time = document.createElement("span"); time.className = "hbu-reader-comments-time"; time.textContent = formatRailAge(child.createAt); who.append(time); } const text = document.createElement("p"); text.className = "hbu-reader-comments-text"; fillReaderCommentBody(text, child.body); const actions = document.createElement("div"); actions.className = "hbu-reader-comments-meta"; if (child.likes > 0) { const likes = document.createElement("span"); likes.textContent = "赞 " + child.likes; actions.append(likes); } if (child.replies > 0) { const replies = document.createElement("span"); replies.textContent = child.replies + " 条回复"; actions.append(replies); } const pendingRow = isPendingReplyRow(child.id); if (pendingRow) { const syncing = document.createElement("span"); const settled = commentState.settledReplies.has(child.id); syncing.className = "hbu-reader-comments-syncing" + (settled ? " hbu-reader-comments-syncing--settled" : ""); syncing.textContent = settled ? "已提交" : "同步中…"; actions.append(syncing); } else { actions.append(createReaderReplyButton({ id: child.id !== "" ? child.id : parent.id, rootId: child.rootId !== "" ? child.rootId : parent.rootId !== "" ? parent.rootId : parent.id, name: child.name })); const deleteButton = createReaderCommentDeleteButton(child); if (deleteButton !== null) actions.append(deleteButton); } const visibleChildren = filterReaderCommentThread(child.children, readHideCyPreference()); const collapsible = child.id !== "" && visibleChildren.length > 0; const hiddenCount = collapsible ? countRailThreadNodes(visibleChildren) : 0; const collapsed = collapsible && collapsedCommentThreads.has(child.id); if (collapsible && !collapsed) actions.append(createThreadCollapseButton(child.id, hiddenCount)); main.append(who, text, actions); if (visibleChildren.length > 0) { if (collapsed) { main.append(createCollapsedThreadRow(child.id, hiddenCount)); } else { const children = document.createElement("ul"); children.className = "hbu-reader-comments-children" + (depth >= RAIL_MAX_INDENT_DEPTH ? " hbu-reader-comments-children--deep" : ""); for (const nested of visibleChildren) children.append(createReaderCommentChild(nested, child, depth + 1)); main.append(children); } } const row = document.createElement("div"); row.className = "hbu-reader-comments-child-row"; row.append(avatar, main); item.append(row); return item; }; const createReaderReplyButton = (target) => { const reply = document.createElement("button"); reply.type = "button"; reply.className = "hbu-reader-comments-reply"; reply.textContent = "回复"; reply.setAttribute("aria-label", "回复 " + target.name); reply.dataset["replyId"] = target.id; reply.dataset["replyRoot"] = target.rootId; reply.dataset["replyName"] = target.name; return reply; }; const renderReaderComments = () => { if (commentPanel === null) return; const hideCy = readHideCyPreference(); const visible = commentState.items.filter((item) => !hideCy || item.isCy !== true); const hiddenCy = commentState.items.length - visible.length; const meta = []; if (commentState.total > 0) meta.push("共 " + commentState.total + " 条"); if (visible.length > 0) meta.push("已加载 " + visible.length + " 条"); if (hiddenCy > 0) meta.push("已屏蔽 " + hiddenCy + " 条 CY"); if (commentPanelMeta !== null) commentPanelMeta.textContent = meta.join(" · "); if (commentPanelList !== null) { commentPanelList.replaceChildren(...visible.map(createReaderCommentItem)); commentPanelList.hidden = visible.length === 0; } if (commentPanelStatus !== null) { if (commentState.error !== "") { commentPanelStatus.hidden = false; if (commentPanelStatusText !== null) commentPanelStatusText.textContent = commentState.error; if (commentPanelRetry !== null) { commentPanelRetry.hidden = false; commentPanelRetry.disabled = commentState.loading; } } else if (commentState.loading && commentState.items.length === 0) { commentPanelStatus.hidden = false; if (commentPanelStatusText !== null) commentPanelStatusText.textContent = "正在加载评论…"; if (commentPanelRetry !== null) commentPanelRetry.hidden = true; } else if (!commentState.loaded) { commentPanelStatus.hidden = false; if (commentPanelStatusText !== null) commentPanelStatusText.textContent = "正在准备评论…"; if (commentPanelRetry !== null) commentPanelRetry.hidden = true; } else if (visible.length === 0) { commentPanelStatus.hidden = false; if (commentPanelStatusText !== null) { commentPanelStatusText.textContent = hiddenCy > 0 ? "已屏蔽全部 " + hiddenCy + " 条 CY 评论" : "这条帖子还没有评论"; } if (commentPanelRetry !== null) commentPanelRetry.hidden = true; } else { commentPanelStatus.hidden = true; if (commentPanelRetry !== null) commentPanelRetry.hidden = true; } } if (commentPanelMore !== null) { commentPanelMore.hidden = !commentState.hasMore; commentPanelMore.disabled = commentState.loading; commentPanelMore.textContent = commentState.loading ? "加载中…" : "加载更多"; } if (commentPanelRefresh !== null) { commentPanelRefresh.disabled = commentState.loading; commentPanelRefresh.dataset["busy"] = commentState.loading ? "true" : "false"; } }; const matchesCommentExpectation = (options, loaded) => { const wantPresent = options.expectPresentId ?? ""; const wantAbsent = options.expectAbsentId ?? ""; if (wantPresent !== "" && !railItemsHaveComment(loaded, wantPresent)) return false; if (wantAbsent !== "" && railItemsHaveComment(loaded, wantAbsent)) return false; const pending = options.expectPending; if (pending !== void 0) { if (!railItemsHavePendingReply(loaded, pending, readPendingBaseline(pending))) return false; } return true; }; const collectReaderCommentSubtree = (nodes, commentId, removed) => { for (const node of nodes) { if (node.id === commentId) { const collectAll = (branch) => { for (const member of branch) { if (member.id !== "") removed.add(member.id); collectAll(member.children); } }; collectAll([node]); return true; } if (collectReaderCommentSubtree(node.children, commentId, removed)) return true; } return false; }; const withoutCommentId = (items, commentId) => { if (commentId === "") return items.slice(); const next = []; for (const entry of items) { if (entry.id === commentId) continue; const removed = /* @__PURE__ */ new Set(); collectReaderCommentSubtree(buildRailCommentTree(entry), commentId, removed); const children = entry.subComments.filter((child) => !removed.has(child.id)); if (children.length === entry.subComments.length) { next.push(entry); continue; } next.push({ ...entry, subComments: children, quotes: children }); } return next; }; const recordFloorPages = (items, page) => { for (const entry of items) { if (entry.id === "") continue; commentState.floorPages.set(entry.id, page); } }; const loadReaderComments = async (page, options = {}) => { if (commentState.loading && options.force !== true) return; const seq = ++commentState.seq; const targetId = articleId; const generationAtLoad = readerGeneration; commentLoadController?.abort(); const controller = new AbortController(); commentLoadController = controller; commentState.loading = true; commentState.error = ""; const merge = options.merge === true; const writeRefresh = options.fresh === true || options.expectPresentId !== void 0 || options.expectAbsentId !== void 0 || options.expectPending !== void 0; const keepItems = merge || page === 1 && writeRefresh; if (page === 1 && !keepItems) { commentState.items = []; commentState.page = 0; commentState.total = 0; commentState.hasMore = false; commentState.loadedGroups = 0; commentState.loaded = false; commentState.floorPages.clear(); } renderReaderComments(); let stale = false; try { const accountId = await resolveHeyboxId(controller.signal); if (seq !== commentState.seq || commentLoadController !== controller) return; if (accountId !== readerAccountId) readerAccountId = accountId; const fresh = merge || page === 1 && writeRefresh; const data = await fetchRailPage(targetId, page, { signal: controller.signal, accountId, bypassCache: fresh, ...page === 1 ? { maxAgeMs: RAIL_PAGE_CACHE_FRESH_MS } : {} }); if (seq !== commentState.seq || commentLoadController !== controller) return; const incoming = withoutCommentId(data.items, options.expectAbsentId ?? ""); const seen = new Set(commentState.items.map((item) => item.id)); const added = incoming.filter((item) => item.id === "" || !seen.has(item.id)); if (merge) { const base = withoutCommentId(commentState.items, options.expectAbsentId ?? ""); commentState.items = mergeRailPageItems(base, incoming); commentState.page = Math.max(commentState.page, page); } else if (page === 1) { commentState.items = incoming; commentState.page = 1; } else { commentState.items = commentState.items.concat(added); commentState.page = page; } recordFloorPages(incoming, page); const absentId = options.expectAbsentId ?? ""; const echoingDeleted = absentId !== "" && railItemsHaveComment(incoming, absentId); commentState.total = echoingDeleted ? commentState.total : data.total > 0 ? data.total : Math.max(commentState.total, commentState.items.length); const beforePrune = commentState.pendingReplies; commentState.pendingReplies = pruneRailPendingReplies(commentState.items, beforePrune, readPendingBaseline); for (const row of beforePrune) { if (commentState.pendingReplies.includes(row)) continue; commentState.pendingBaselines.delete(row.id); commentState.settledReplies.delete(row.id); } const progress = merge ? resolveMergedCommentProgress({ loadedGroups: commentState.items.length, floorTotal: data.floorTotal, rawGroupCount: incoming.length }) : resolveCommentPageProgress({ rawGroupCount: data.items.length, freshGroupCount: page === 1 ? data.items.length : added.length, previouslyLoadedGroups: page === 1 ? 0 : commentState.loadedGroups, floorTotal: data.floorTotal }); commentState.loadedGroups = progress.loadedGroups; commentState.hasMore = progress.hasMore; commentState.loaded = true; if (page === 1 || merge) commentState.loadedAt = Date.now(); if (linkState !== null && !echoingDeleted && Number.isFinite(data.total) && data.total > 0) { const current = Number(linkState.commentCount); linkState.commentCount = Math.max(Number.isFinite(current) ? current : 0, data.total); } syncActionStates(); stale = !matchesCommentExpectation(options, commentState.items); } catch (error) { if (isAbortError(error)) return; if (seq !== commentState.seq || commentLoadController !== controller) return; const message = error instanceof Error ? error.message : "评论加载失败"; commentState.error = /captcha|show_captcha/i.test(message) ? "评论需要验证,请稍后重试" : "评论加载失败:" + message; commentState.hasMore = false; } finally { if (seq === commentState.seq && commentLoadController === controller) { commentState.loading = false; commentLoadController = null; renderReaderComments(); } } if (!stale) return; if (articleId !== targetId || readerGeneration !== generationAtLoad) return; if (seq !== commentState.seq) return; const attempt = (options.attempt ?? 0) + 1; const pending = options.expectPending; const maxAttempts = pending === void 0 ? COMMENT_VERIFY_ATTEMPTS : COMMENT_VERIFY_MAX_ATTEMPTS; if (attempt >= maxAttempts) { if (pending !== void 0) { commentState.settledReplies.add(pending.id); renderReaderComments(); } if (options.expectAbsentId !== void 0) showToast("评论已删除,列表仍待站点同步"); else if ((options.expectPresentId ?? "") !== "") showToast("评论已提交,列表仍待站点同步"); return; } if (pending !== void 0 && attempt === COMMENT_VERIFY_ATTEMPTS) { commentState.settledReplies.add(pending.id); renderReaderComments(); } const ownedPage = options.verifyPage ?? page; const nextPage = attempt % 3 === 2 && page !== 1 ? 1 : ownedPage; void waitForDelay(readCommentVerifyDelay(attempt), controller.signal).then(() => { if (articleId !== targetId || readerGeneration !== generationAtLoad) return; if (seq !== commentState.seq) return; void loadReaderComments(nextPage, { ...options, fresh: true, force: true, merge: true, attempt }); }).catch(() => void 0); }; const readArticleComposers = () => Array.from(document.querySelectorAll("[data-hbu-article-composer]")); const clearArticleReplyTarget = (restoreFocus) => { if (articleReplyTarget === null) return; articleReplyTarget = null; syncArticleComposers(); if (restoreFocus) readArticleComposers()[0]?.querySelector(".hbu-reader-composer-input")?.focus({ preventScroll: true }); }; const syncArticleComposers = () => { const reply = articleReplyTarget; for (const root of readArticleComposers()) { const input = root.querySelector(".hbu-reader-composer-input"); const submit = root.querySelector(".hbu-reader-composer-submit"); if (!(input instanceof HTMLTextAreaElement) || !(submit instanceof HTMLButtonElement)) continue; submit.disabled = articleCommentBusy || input.value.trim() === ""; submit.textContent = articleCommentBusy ? "发布中…" : "发布"; const replyBar = root.querySelector(".hbu-reader-composer-reply"); const replyName = root.querySelector(".hbu-reader-composer-reply-name"); if (replyBar instanceof HTMLElement) replyBar.hidden = reply === null; if (replyName !== null) replyName.textContent = reply === null ? "" : "回复 @" + reply.name; input.placeholder = reply === null ? "评论这篇文章…" : "回复 @" + reply.name + "…"; input.setAttribute("aria-label", reply === null ? "评论内容" : "回复 " + reply.name); } }; const clearArticleComposers = () => { for (const root of readArticleComposers()) { articleComposerEmojiClosers.get(root)?.(); const input = root.querySelector(".hbu-reader-composer-input"); const status = root.querySelector(".hbu-reader-composer-status"); const count = root.querySelector(".hbu-reader-composer-count"); if (input instanceof HTMLTextAreaElement) input.value = ""; if (status !== null) status.textContent = ""; if (count !== null) count.textContent = "0/" + ARTICLE_COMMENT_MAX_LENGTH; } articleReplyTarget = null; syncArticleComposers(); }; const submitArticleComment = async (input, status) => { if (articleCommentBusy) return; const text = input.value.trim(); if (text === "") { status.textContent = "请输入评论内容"; input.focus({ preventScroll: true }); return; } const targetId = articleId; const generation = readerGeneration; const reply = articleReplyTarget; articleCommentBusy = true; syncArticleComposers(); status.textContent = ""; try { const accountId = await resolveHeyboxId(); if (articleId !== targetId || readerGeneration !== generation) return; if (accountId === "") throw new Error("请先登录小黑盒"); const created = await postReaderApi(COMMENT_CREATE_API_PATH, buildArticleCommentBody(targetId, text, reply), accountId); if (articleId !== targetId || readerGeneration !== generation) return; const createdId = readCreatedCommentId(created); clearArticleComposers(); showToast("评论已发布"); articleCommentPublishedCount += 1; holdRailPageCache(targetId); if (linkState !== null) { linkState.commentCount = Math.max(0, Number(linkState.commentCount) || 0) + 1; } else { void loadLinkState(accountId); } syncActionStates(); if (commentPanel !== null && !commentPanel.hidden) { const pending = reply === null ? null : createRailPendingReply({ parentId: reply.id, rootId: reply.rootId !== "" ? reply.rootId : reply.id, text, userId: accountId, createAt: Math.floor(Date.now() / 1e3), sequence: ++pendingReplySeq, ...createdId === "" ? {} : { commentId: createdId } }); if (pending !== null) { const pendingFloorId = resolvePendingFloorId(commentState.items, pending); if (pendingFloorId !== "") collapsedCommentThreads.delete(pendingFloorId); commentState.pendingBaselines.set(pending.id, collectRailCommentIds(commentState.items)); commentState.pendingReplies.push(pending); renderReaderComments(); } const floorId = pending === null ? "" : resolvePendingFloorId(commentState.items, pending); const page = floorId === "" ? 1 : commentState.floorPages.get(floorId) ?? 1; void loadReaderComments(page, { fresh: true, force: true, merge: true, verifyPage: page, ...pending !== null ? { expectPending: pending } : createdId === "" ? {} : { expectPresentId: createdId } }); } } catch (error) { if (articleId === targetId && readerGeneration === generation && status.isConnected) { status.textContent = error instanceof Error ? error.message : "评论发布失败"; } } finally { if (articleId === targetId && readerGeneration === generation) { articleCommentBusy = false; syncArticleComposers(); } } }; const findReaderCommentNode = (nodes, commentId) => { for (const node of nodes) { if (node.id === commentId) return node; const nested = findReaderCommentNode(node.children, commentId); if (nested !== null) return nested; } return null; }; const readReaderCommentById = (commentId) => { for (const entry of commentState.items) { if (entry.id === commentId) return entry; const nested = findReaderCommentNode(buildRailCommentTree(entry), commentId); if (nested !== null) return nested; } return null; }; const collectReaderThreadIds = (nodes, commentId) => { for (const node of nodes) { if (node.id === commentId) { const ids = /* @__PURE__ */ new Set(); const collect = (list) => { for (const member of list) { if (member.id !== "") ids.add(member.id); collect(member.children); } }; collect([node]); return ids; } const nested = collectReaderThreadIds(node.children, commentId); if (nested !== null) return nested; } return null; }; const removeDeletedComment = (commentId) => { let removed = 0; const nextItems = []; for (const entry of commentState.items) { if (entry.id === commentId) { removed += 1 + (Array.isArray(entry.subComments) ? entry.subComments.length : 0); continue; } const visibleChildren = Array.isArray(entry.subComments) ? entry.subComments : []; const subtree = collectReaderThreadIds(buildRailCommentTree(entry), commentId); if (subtree === null) { nextItems.push(entry); continue; } const keptChildren = visibleChildren.filter((child) => !subtree.has(child.id)); removed += visibleChildren.length - keptChildren.length; nextItems.push({ ...entry, subComments: keptChildren, quotes: keptChildren }); } if (removed === 0) return 0; commentState.items = nextItems; return removed; }; const deleteReaderComment = async (commentId) => { if (commentId === "" || commentDeleteBusy.has(commentId)) return; const owned = readReaderCommentById(commentId); if (owned === null || !isOwnRailComment(owned, readerAccountId)) return; const targetId = articleId; const generation = readerGeneration; if (typeof window.confirm === "function" && !window.confirm("删除这条评论?")) return; commentDeleteBusy.add(commentId); renderReaderComments(); try { const accountId = await resolveHeyboxId(); if (accountId === "") throw new Error("请先登录小黑盒"); await postReaderApi(DELETE_COMMENT_API_PATH, buildDeleteCommentBody(commentId), accountId); if (articleId !== targetId || readerGeneration !== generation) return; if (articleReplyTarget !== null && articleReplyTarget.id === commentId) clearArticleReplyTarget(false); const removedCount = removeDeletedComment(commentId); commentState.total = Math.max(0, commentState.total - removedCount); if (linkState !== null) linkState.commentCount = Math.max(0, (Number(linkState.commentCount) || 0) - removedCount); holdRailPageCache(targetId); syncActionStates(); showToast("评论已删除"); if (commentPanel !== null && !commentPanel.hidden) { const floorId = owned.rootId !== "" ? owned.rootId : commentId; const page = commentState.floorPages.get(floorId) ?? 1; void loadReaderComments(page, { fresh: true, force: true, merge: true, expectAbsentId: commentId }); } } catch (error) { if (articleId === targetId && readerGeneration === generation) { showToast(error instanceof Error ? error.message : "评论删除失败"); } } finally { commentDeleteBusy.delete(commentId); if (articleId === targetId && readerGeneration === generation) renderReaderComments(); } }; const createComposerEmojiPicker = (input, status, toggle, panel) => { const grid = document.createElement("div"); grid.className = "hbu-reader-composer-emoji-grid"; let isOpen = false; let outsideBound = false; const detachOutside = () => { if (!outsideBound) return; document.removeEventListener("pointerdown", onOutsidePointer, true); outsideBound = false; }; const close = (restoreFocus) => { if (!isOpen) return; isOpen = false; panel.hidden = true; toggle.setAttribute("aria-expanded", "false"); detachOutside(); if (restoreFocus === true && toggle.isConnected) toggle.focus({ preventScroll: true }); }; const onOutsidePointer = (event) => { const target = event.target; if (target instanceof Node && (panel.contains(target) || toggle.contains(target))) return; close(false); }; const focusAt = (index) => { if (buttons.length === 0) return; buttons[Math.min(buttons.length - 1, Math.max(0, index))].focus({ preventScroll: true }); }; const insert = (name) => { const token = "[cube_" + name + "]"; const original = input.value; const start = input.selectionStart ?? original.length; const end = input.selectionEnd ?? start; if (original.length - (end - start) + token.length > ARTICLE_COMMENT_MAX_LENGTH) { input.setSelectionRange(start, end); status.textContent = "评论最多 " + ARTICLE_COMMENT_MAX_LENGTH + " 字,无法插入表情"; return; } status.textContent = ""; input.setRangeText(token, start, end, "end"); input.dispatchEvent(new Event("input", { bubbles: true })); input.focus({ preventScroll: true }); }; const open = () => { if (isOpen) return; isOpen = true; panel.hidden = false; toggle.setAttribute("aria-expanded", "true"); document.addEventListener("pointerdown", onOutsidePointer, true); outsideBound = true; focusAt(0); }; const buttons = RAIL_EMOJI_NAMES.map((name, index) => { const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-label", name); button.title = name; const glyph = document.createElement("span"); const scale = 22 / RAIL_EMOJI_CELL; glyph.style.backgroundImage = 'url("' + RAIL_EMOJI_SPRITE + '")'; glyph.style.backgroundSize = 102 * scale + "px " + 306 * scale + "px"; glyph.style.backgroundPosition = "-" + index % 6 * 22 + "px -" + Math.floor(index / 6) * 22 + "px"; button.append(glyph); button.addEventListener("click", () => insert(name)); grid.append(button); return button; }); panel.append(grid); panel.addEventListener("keydown", (event) => { const active = document.activeElement; const index = active instanceof HTMLButtonElement ? buttons.indexOf(active) : -1; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(true); return; } if (event.key === "Home") { event.preventDefault(); focusAt(0); return; } if (event.key === "End") { event.preventDefault(); focusAt(buttons.length - 1); return; } const delta = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : event.key === "ArrowUp" ? -6 : event.key === "ArrowDown" ? 6 : 0; if (delta === 0) return; event.preventDefault(); focusAt((index < 0 ? 0 : index) + delta); }); toggle.addEventListener("click", () => { if (isOpen) close(true); else open(); }); return { close }; }; const createArticleComposer = () => { const root = document.createElement("div"); root.className = "hbu-reader-composer"; root.dataset["hbuArticleComposer"] = ""; const replyBar = document.createElement("div"); replyBar.className = "hbu-reader-composer-reply"; replyBar.hidden = true; const replyText = document.createElement("span"); replyText.className = "hbu-reader-composer-reply-name"; const replyCancel = document.createElement("button"); replyCancel.type = "button"; replyCancel.className = "hbu-reader-composer-reply-cancel"; replyCancel.textContent = "取消回复"; replyCancel.addEventListener("click", () => clearArticleReplyTarget(true)); replyBar.append(replyText, replyCancel); const input = document.createElement("textarea"); input.className = "hbu-reader-composer-input"; input.maxLength = ARTICLE_COMMENT_MAX_LENGTH; input.rows = 2; input.placeholder = "评论这篇文章…"; input.setAttribute("aria-label", "评论内容"); const bar = document.createElement("div"); bar.className = "hbu-reader-composer-bar"; const status = document.createElement("div"); status.className = "hbu-reader-composer-status"; status.setAttribute("role", "status"); const count = document.createElement("span"); count.className = "hbu-reader-composer-count"; count.textContent = "0/" + ARTICLE_COMMENT_MAX_LENGTH; const submit = document.createElement("button"); submit.type = "button"; submit.className = "hbu-reader-composer-submit"; submit.textContent = "发布"; submit.disabled = true; const emojiToggle = document.createElement("button"); emojiToggle.type = "button"; emojiToggle.className = "hbu-reader-composer-emoji-toggle"; emojiToggle.textContent = "表情"; emojiToggle.setAttribute("aria-label", "插入表情"); emojiToggle.setAttribute("aria-expanded", "false"); const emojiPanel = document.createElement("div"); emojiPanel.className = "hbu-reader-composer-emoji"; emojiPanel.hidden = true; emojiPanel.setAttribute("role", "group"); emojiPanel.setAttribute("aria-label", "表情列表"); const emojiPicker = createComposerEmojiPicker(input, status, emojiToggle, emojiPanel); root.addEventListener("keydown", (event) => { if (event.key !== "Escape" || emojiPanel.hidden) return; event.preventDefault(); event.stopPropagation(); emojiPicker.close(true); }, true); input.addEventListener("input", () => { count.textContent = input.value.length + "/" + ARTICLE_COMMENT_MAX_LENGTH; submit.disabled = articleCommentBusy || input.value.trim() === ""; }); input.addEventListener("keydown", (event) => { if (event.key !== "Enter" || !event.ctrlKey && !event.metaKey) return; event.preventDefault(); if (!submit.disabled) void submitArticleComment(input, status); }); submit.addEventListener("click", () => void submitArticleComment(input, status)); bar.append(emojiToggle, status, count, submit); root.append(replyBar, input, emojiPanel, bar); articleComposerEmojiClosers.set(root, () => emojiPicker.close(false)); return root; }; const trapCommentPanelTab = (event) => { if (event.key !== "Tab" || commentPanel === null) return; const items = Array.from(commentPanel.querySelectorAll("button, a[href], textarea, input, [tabindex]:not([tabindex='-1'])")).filter((node) => node.getBoundingClientRect().width > 0 && !node.hasAttribute("disabled")); if (items.length === 0) return; const first = items[0]; const last = items[items.length - 1]; if (first === void 0 || last === void 0) return; if (event.shiftKey && (document.activeElement === first || !commentPanel.contains(document.activeElement))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; const ensureCommentPanel = () => { if (commentPanel !== null) { if (commentPanel.isConnected) return commentPanel; commentPanel = null; commentPanelList = null; commentPanelStatus = null; commentPanelStatusText = null; commentPanelRetry = null; commentPanelMeta = null; commentPanelMore = null; commentPanelClose = null; commentPanelRefresh = null; } const panel = document.createElement("section"); panel.className = "hbu-reader-comments-panel"; panel.hidden = true; const scrim = document.createElement("div"); scrim.className = "hbu-reader-comments-scrim"; const sheet = document.createElement("div"); sheet.className = "hbu-reader-comments-sheet"; sheet.setAttribute("role", "dialog"); sheet.setAttribute("aria-modal", "true"); sheet.setAttribute("aria-label", "评论"); const head = document.createElement("div"); head.className = "hbu-reader-comments-head"; const heading = document.createElement("div"); heading.className = "hbu-reader-comments-heading"; const title = document.createElement("b"); title.textContent = "评论"; const meta = document.createElement("span"); meta.textContent = ""; heading.append(title, meta); const refresh = document.createElement("button"); refresh.type = "button"; refresh.className = "hbu-reader-comments-refresh"; refresh.setAttribute("aria-label", "刷新评论"); refresh.title = "刷新评论"; refresh.innerHTML = READER_ICONS.refresh; const close = document.createElement("button"); close.type = "button"; close.className = "hbu-reader-comments-close"; close.setAttribute("aria-label", "关闭评论"); close.innerHTML = READER_ICONS.close; head.append(heading, refresh, close); const composer = createArticleComposer(); const body = document.createElement("div"); body.className = "hbu-reader-comments-body"; const list = document.createElement("ol"); list.className = "hbu-reader-comments-list"; const status = document.createElement("div"); status.className = "hbu-reader-comments-status"; status.setAttribute("role", "group"); status.setAttribute("aria-label", "评论加载状态"); const statusText = document.createElement("div"); statusText.setAttribute("role", "status"); statusText.setAttribute("aria-live", "polite"); const retry = document.createElement("button"); retry.type = "button"; retry.className = "hbu-reader-comments-status-retry"; retry.textContent = "重试"; retry.hidden = true; status.append(statusText, retry); body.append(list, status); const foot = document.createElement("div"); foot.className = "hbu-reader-comments-foot"; const more = document.createElement("button"); more.type = "button"; more.textContent = "加载更多"; const openLink = document.createElement("a"); openLink.textContent = "打开帖子"; openLink.href = LINK_HREF_PREFIX + articleId; openLink.target = "_blank"; openLink.rel = "noopener noreferrer"; foot.append(more, openLink); sheet.append(head, composer, body, foot); panel.append(scrim, sheet); commentPanel = panel; commentPanelList = list; commentPanelStatus = status; commentPanelStatusText = statusText; commentPanelRetry = retry; commentPanelMeta = meta; commentPanelMore = more; commentPanelClose = close; commentPanelRefresh = refresh; close.addEventListener("click", closeComments); scrim.addEventListener("click", closeComments); refresh.addEventListener("click", () => { if (commentState.loading) return; void loadReaderComments(1, { fresh: true, force: true, merge: commentState.page > 1 }); }); more.addEventListener("click", () => { if (!commentState.loading) void loadReaderComments(commentState.page + 1); }); retry.addEventListener("click", () => { if (commentState.loading) return; commentPanelClose?.focus({ preventScroll: true }); void loadReaderComments(commentState.page > 0 ? commentState.page + 1 : 1); }); list.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; const collapse = target.closest(".hbu-reader-comments-collapse"); if (collapse instanceof HTMLElement) { const threadId = collapse.dataset["threadId"] || ""; if (threadId === "") return; event.preventDefault(); collapsedCommentThreads.add(threadId); renderReaderComments(); focusCommentThreadControl(".hbu-reader-comments-expand", threadId); return; } const expand = target.closest(".hbu-reader-comments-expand"); if (expand instanceof HTMLElement) { const threadId = expand.dataset["threadId"] || ""; if (threadId === "") return; event.preventDefault(); collapsedCommentThreads.delete(threadId); renderReaderComments(); focusCommentThreadControl(".hbu-reader-comments-collapse", threadId); return; } const remove = target.closest(".hbu-reader-comments-delete"); if (remove instanceof HTMLElement) { const deleteId = remove.dataset["deleteId"] || ""; if (deleteId === "") return; event.preventDefault(); void deleteReaderComment(deleteId); return; } const reply = target.closest(".hbu-reader-comments-reply"); if (!(reply instanceof HTMLElement)) return; const id = reply.dataset["replyId"] || ""; const rootId = reply.dataset["replyRoot"] || id; const name = reply.dataset["replyName"] || ""; if (id === "") return; event.preventDefault(); articleReplyTarget = { id, rootId, name }; syncArticleComposers(); readArticleComposers()[0]?.querySelector(".hbu-reader-composer-input")?.focus({ preventScroll: true }); }); sheet.addEventListener("keydown", trapCommentPanelTab); document.body.append(panel); return panel; }; const isCommentPanelOpen = () => commentPanel !== null && !commentPanel.hidden; const openComments = () => { if (host === null) return; closeFavourMenu(); closeCyDialog(); if (isCommentPanelOpen()) { closeComments(); return; } const panel = ensureCommentPanel(); panel.hidden = false; host.inert = true; renderReaderComments(); commentPanelClose?.focus({ preventScroll: true }); syncArticleComposers(); if (commentState.loading) return; const stalePanel = commentState.loaded && Date.now() - commentState.loadedAt > COMMENT_PANEL_STALE_MS; if (!commentState.loaded) { void loadReaderComments(1, { fresh: true, force: true }); } else if (stalePanel) { void loadReaderComments(1, { fresh: true, force: true, merge: commentState.page > 1 }); } }; const resetReaderComments = () => { commentLoadController?.abort(); commentLoadController = null; articleCommentBusy = false; commentDeleteBusy.clear(); collapsedCommentThreads.clear(); clearArticleComposers(); if (host !== null) host.inert = false; if (commentPanel !== null) { commentPanel.hidden = true; const link = commentPanel.querySelector(".hbu-reader-comments-foot a"); if (link !== null) link.href = LINK_HREF_PREFIX + articleId; } commentState = createCommentState(); }; document.addEventListener(HIDE_CY_EVENT, () => { if (commentPanel !== null && !commentPanel.hidden) renderReaderComments(); }); const closeComments = () => { if (commentPanel === null || commentPanel.hidden) return; commentPanel.hidden = true; if (host !== null) host.inert = false; host?.querySelector('[data-action="comment"]')?.focus({ preventScroll: true }); }; const openLightbox = (source) => { if (source === "") return; if (lightbox === null) { lightbox = document.createElement("button"); lightbox.type = "button"; lightbox.className = "hbu-reader-lightbox"; lightbox.setAttribute("aria-label", "关闭大图"); const img2 = document.createElement("img"); lightbox.append(img2); lightbox.addEventListener("click", () => { lightbox?.remove(); lightbox = null; }); document.body.append(lightbox); } const img = lightbox.querySelector("img"); if (img !== null) img.src = source; lightbox.focus({ preventScroll: true }); }; const scrollToPage = (index) => { if (host === null) return; const pages = Array.from(host.querySelectorAll(".hbu-reader-page")); if (pages.length === 0) return; const next = Math.min(pages.length - 1, Math.max(0, index)); const target = pages[next]; if (target === void 0) return; const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; target.scrollIntoView({ block: "start", behavior }); }; const scrollViewport = (direction) => { const scroll = host?.querySelector(".hbu-reader-scroll"); if (!scroll) return; const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; scroll.scrollBy({ top: Math.round(scroll.clientHeight * 0.82) * direction, behavior }); }; const coverInView = () => { const scroll = host?.querySelector(".hbu-reader-scroll"); const hero = host?.querySelector('.hbu-reader-page[data-kind="hero"]'); if (!scroll || !hero) return false; return hero.getBoundingClientRect().bottom > scroll.clientHeight * 0.45; }; const bindSwipe = (scroll) => { let startX = 0; let startY = 0; scroll.addEventListener("touchstart", (event) => { if (event.touches.length !== 1) return; startX = event.touches[0].clientX; startY = event.touches[0].clientY; }, { passive: true }); scroll.addEventListener("touchend", (event) => { if (event.changedTouches.length !== 1) return; const deltaX = event.changedTouches[0].clientX - startX; const deltaY = event.changedTouches[0].clientY - startY; if (Math.abs(deltaX) < 52 || Math.abs(deltaX) < Math.abs(deltaY) * 1.3) return; if (coverInView() && coverTotal > 1) { if (deltaX < 0 && coverIndex === coverTotal - 1) scrollToPage(1); else if (deltaX > 0 && coverIndex === 0) scrollToPage(0); else setCover(coverIndex + (deltaX < 0 ? 1 : -1)); return; } const page = host?.querySelectorAll(".hbu-reader-page")[activeIndex]; if (page !== void 0 && page.scrollWidth > page.clientWidth + 8) return; scrollViewport(deltaX < 0 ? 1 : -1); }, { passive: true }); }; const setCover = (value) => { if (host === null || coverTotal === 0) return; coverIndex = (value + coverTotal) % coverTotal; const sources = Array.from(host.querySelectorAll(".hbu-reader-cover img")); const glow = host.querySelector(".hbu-reader-cover-glow"); if (glow !== null && sources[coverIndex] !== void 0) { glow.style.backgroundImage = 'url("' + readImageSource(sources[coverIndex]).replace(/"/g, '\\"') + '")'; } for (const [index, node] of sources.entries()) { node.dataset["active"] = String(index === coverIndex); } for (const [index, node] of Array.from(host.querySelectorAll(".hbu-reader-cover-dots button")).entries()) { node.setAttribute("aria-current", String(index === coverIndex)); } }; const bindTag = (node, text) => { node.addEventListener("click", (event) => { event.preventDefault(); const native = Array.from(document.querySelectorAll("#page-bbs-link .link-tags__tag-item")).find((candidate) => (candidate.textContent || "").trim() === text); if (native instanceof HTMLElement) native.click(); }); }; const bindRichProxy = (clone, native) => { const tags = Array.from(clone.querySelectorAll("button")); for (const button of tags) { const text = (button.textContent || "").trim(); const className = typeof button.className === "string" ? button.className : ""; button.addEventListener("click", (event) => { event.preventDefault(); const candidates = Array.from(native.querySelectorAll("button")).filter((node) => (typeof node.className === "string" ? node.className : "") === className && (node.textContent || "").trim() === text); if (candidates[0] instanceof HTMLElement) candidates[0].click(); }); } for (const link of Array.from(clone.querySelectorAll("a[href]"))) { if ((link.getAttribute("href") || "").startsWith("#")) link.addEventListener("click", (event) => event.preventDefault()); } for (const image of Array.from(clone.querySelectorAll("img"))) { const source = readImageSource(image); if (source !== "") image.src = source; image.loading = "lazy"; image.decoding = "async"; image.addEventListener("click", () => openLightbox(readImageSource(image))); } }; const EMBED_TAGS = /* @__PURE__ */ new Set(["iframe", "video", "audio", "embed", "object"]); const EMBED_SELECTOR = EMBEDDED_MEDIA_SELECTOR; const isInlineHidden = (node) => node instanceof HTMLElement && (node.hasAttribute("hidden") || node.style.display === "none" || node.style.visibility === "hidden"); const isEmbedElement = (node) => node instanceof Element && EMBED_TAGS.has(node.tagName.toLowerCase()); const isEmbedWrapper = (node) => node instanceof Element && node.tagName.toLowerCase() === "div" && node.classList.contains("iframe"); const hasHiddenInlineAncestor = (node) => { let current = node.parentElement; while (current !== null) { if (isInlineHidden(current)) return true; current = current.parentElement; } return false; }; const isComputedHidden = (node) => { if (!(node instanceof Element) || !node.isConnected) return false; const visibility = window.getComputedStyle(node).visibility; if (visibility === "hidden" || visibility === "collapse") return true; return hasHiddenDisplayAncestor(node); }; const hasHiddenDisplayAncestor = (node) => { let current = node; while (current !== null && !current.matches(READER_SOURCE_ROOT_SELECTOR)) { if (window.getComputedStyle(current).display === "none") return true; current = current.parentElement; } return false; }; const isUnavailableEmbed = (node) => isInlineHidden(node) || hasHiddenInlineAncestor(node) || isComputedHidden(node); const isHiddenEmbedNode = (node) => (isEmbedElement(node) || isEmbedWrapper(node)) && isUnavailableEmbed(node); const readVisibleEmbeds = (node) => Array.from(node.querySelectorAll(EMBED_SELECTOR)).filter((embed) => !isUnavailableEmbed(embed)); const isTextFreeContainer = (node) => { const text = (node.textContent || "").replace(/\s+/g, " ").trim(); return text === "" && node.querySelector("img, picture") === null; }; const readSingleSourceGroup = (source, embeds) => embeds.length === 1 ? { source, embeds } : null; const readContainerEmbedGroup = (node, embeds) => { if (embeds.length === 0) return null; if (isEmbedWrapper(node)) return readSingleSourceGroup(node, embeds); return isTextFreeContainer(node) ? readSingleSourceGroup(embeds[0], embeds) : null; }; const readEmbedGroup = (node) => { if (!(node instanceof Element) || isUnavailableEmbed(node)) return null; if (EMBED_TAGS.has(node.tagName.toLowerCase())) return { source: node, embeds: [node] }; return readContainerEmbedGroup(node, readVisibleEmbeds(node)); }; const readEmbedGroups = (root) => { if (!(root instanceof Element)) return []; const groups = []; const collect = (node) => { if (!node.matches(EMBED_SELECTOR) && node.querySelector(EMBED_SELECTOR) === null) return; const group = readEmbedGroup(node); if (group !== null) { groups.push(group); return; } for (const child of Array.from(node.children)) collect(child); }; collect(root); return groups; }; const containsEmbedOrWrapper = (node) => node.matches(EMBED_SELECTOR) || isEmbedWrapper(node) || node.querySelector(EMBED_SELECTOR) !== null; const readMediaSequenceItem = (node) => { if (node instanceof HTMLImageElement) return { kind: "image", node }; if (!containsEmbedOrWrapper(node)) return null; const group = readEmbedGroup(node); return group === null ? null : { kind: "embed", group }; }; const readMediaSequence = (root) => { if (!(root instanceof Element)) return []; const items = []; const collect = (node) => { const item = readMediaSequenceItem(node); if (item !== null) { items.push(item); return; } for (const child of Array.from(node.children)) collect(child); }; collect(root); return items; }; const readImageSource = (node) => { if (!(node instanceof HTMLImageElement)) return ""; for (const name of ["data-src", "data-original"]) { const value = (node.getAttribute(name) || "").trim(); if (value !== "" && !/^(?:data|blob):/i.test(value)) return value; } for (const value of [node.currentSrc, node.getAttribute("src") || ""]) { const source = value.trim(); if (source !== "" && !/^(?:data|blob):/i.test(source)) return source; } return ""; }; const readVideoSource = (node) => { if (!(node instanceof HTMLVideoElement)) return ""; const values = [ ...Array.from(node.querySelectorAll("source")).map((source) => source.getAttribute("src") || ""), node.getAttribute("src") || "", node.currentSrc ]; for (const raw of values) { const source = raw.trim(); if (source === "" || /^(?:javascript|data):/i.test(source)) continue; return source; } return ""; }; const readPosterSource = (node) => { if (!(node instanceof HTMLVideoElement)) return ""; const poster = (node.getAttribute("poster") || "").trim(); return poster !== "" && !/^(?:data|blob):/i.test(poster) ? poster : ""; }; const readHeaderImages = (pageRoot) => { const seen = /* @__PURE__ */ new Set(); const sources = []; if (!(pageRoot instanceof Element)) return sources; for (const node of Array.from(pageRoot.querySelectorAll(READER_SOURCE_HEADER_SELECTOR))) { const source = readImageSource(node); if (source === "" || seen.has(source)) continue; seen.add(source); sources.push(source); } return sources; }; const readOrderedImageSources = (scope) => { if (!(scope instanceof Element)) return []; const seen = /* @__PURE__ */ new Set(); const sources = []; for (const node of Array.from(scope.querySelectorAll("img"))) { if (node.closest(READER_MEDIA_EXCLUDE_SELECTOR) !== null) continue; const source = readImageSource(node); if (source === "" || seen.has(source)) continue; seen.add(source); sources.push(source); } return sources; }; const readBodyImages = (article, body) => { const scope = body || article; if (!(scope instanceof Element)) return []; const seen = /* @__PURE__ */ new Set(); const candidates = []; for (const [index, node] of Array.from(scope.querySelectorAll("img")).entries()) { if (node.closest(READER_MEDIA_EXCLUDE_SELECTOR) !== null) continue; const source = readImageSource(node); if (source === "" || seen.has(source)) continue; seen.add(source); const naturalWidth = node.naturalWidth; const naturalHeight = node.naturalHeight; const rect = node.getBoundingClientRect(); const declaredWidth = Number(node.getAttribute("width")) || 0; const declaredHeight = Number(node.getAttribute("height")) || 0; const width = naturalWidth > 0 ? naturalWidth : rect.width > 0 ? rect.width : declaredWidth; const height = naturalHeight > 0 ? naturalHeight : rect.height > 0 ? rect.height : declaredHeight; if (width > 0 && width < 120) continue; candidates.push({ source, area: width * height, loaded: naturalWidth > 0, index }); } candidates.sort((left, right) => Number(right.loaded) - Number(left.loaded) || right.area - left.area || left.index - right.index); return candidates.map((item) => item.source); }; const readPositiveDimension = (raw, pattern) => { if (!pattern.test(raw)) return 0; const value = Number.parseFloat(raw); return Number.isFinite(value) && value > 0 ? value : 0; }; const readEmbedDimension = (node, name) => { const inlineValue = node instanceof HTMLElement ? node.style[name].trim() : ""; if (/^\d+(\.\d+)?px$/.test(inlineValue)) return readPositiveDimension(inlineValue, /^\d+(\.\d+)?px$/); const attributeValue = (node.getAttribute(name) || "").trim(); return readPositiveDimension(attributeValue, /^\d+(\.\d+)?$/); }; const readEmbedHeight = (node) => readEmbedDimension(node, "height"); const readEmbedWidth = (node) => readEmbedDimension(node, "width"); const isAudioOnlyGroup = (embeds) => embeds.length > 0 && embeds.every((embed) => embed.tagName.toLowerCase() === "audio"); const SUSPENDED_SOURCE_MARKER = "heybox-up-suspended"; const readSuspendedSourceValue = (name) => name === "srcdoc" ? '' : `about:blank#${SUSPENDED_SOURCE_MARKER}`; const reconcileSuspendedSourceMutations = (embed, state, records) => { if (suspendedEmbeds.get(embed) !== state) return; for (const record of records) { const name = record.attributeName; if (name === null) continue; delete state.attributes[name]; } }; const suspendMediaEmbed = (embed) => { suspendedEmbeds.set(embed, { kind: "media", muted: embed.muted, currentTime: Number.isFinite(embed.currentTime) ? embed.currentTime : 0 }); embed.muted = true; embed.pause(); }; const suspendMarkupEmbed = (embed) => { const attributes = {}; for (const name of EMBEDDED_MEDIA_SOURCE_ATTRIBUTES) { const value = embed.getAttribute(name); if (value !== null) attributes[name] = value; } const state = { kind: "markup", attributes, sourceObserver: null }; const sourceObserver = new MutationObserver((records) => reconcileSuspendedSourceMutations(embed, state, records)); state.sourceObserver = sourceObserver; sourceObserver.observe(embed, { attributes: true, attributeFilter: Array.from(EMBEDDED_MEDIA_SOURCE_ATTRIBUTES) }); suspendedEmbeds.set(embed, state); for (const name of Object.keys(attributes)) embed.setAttribute(name, readSuspendedSourceValue(name)); sourceObserver.takeRecords(); }; const suspendNativeEmbed = (embed) => { if (suspendedEmbeds.has(embed)) return; if (embed instanceof HTMLMediaElement) { suspendMediaEmbed(embed); return; } suspendMarkupEmbed(embed); }; const restoreSuspendedMedia = (embed, state) => { embed.muted = state.muted; if (state.currentTime <= 0 || embed.readyState <= 0) return; try { embed.currentTime = state.currentTime; } catch (error) { console.debug("[HeyBox Up] Failed to restore embedded media position", error); } }; const restoreSuspendedMarkup = (embed, state) => { if (state.sourceObserver !== null) { reconcileSuspendedSourceMutations(embed, state, state.sourceObserver.takeRecords()); state.sourceObserver.disconnect(); } for (const [name, value] of Object.entries(state.attributes)) embed.setAttribute(name, value); }; const restoreSuspendedEmbed = (embed, state) => { if (state.kind === "markup") { restoreSuspendedMarkup(embed, state); return; } if (embed instanceof HTMLMediaElement) restoreSuspendedMedia(embed, state); }; const restoreSuspendedEmbeds = () => { for (const [embed, state] of suspendedEmbeds) restoreSuspendedEmbed(embed, state); suspendedEmbeds.clear(); }; const removeUnavailableEmbedClones = (clone, source) => { const nativeEmbeds = Array.from(source.querySelectorAll(EMBED_SELECTOR)); const clonedEmbeds = Array.from(clone.querySelectorAll(EMBED_SELECTOR)); nativeEmbeds.forEach((embed, index) => { const cloned = clonedEmbeds[index]; if (cloned instanceof Element && isUnavailableEmbed(embed)) cloned.remove(); }); }; const readGroupDimension = (group, reader) => { const ownValue = reader(group.source); if (!isEmbedWrapper(group.source) || ownValue > 0) return ownValue; const declared = group.embeds.find((embed) => reader(embed) > 0); return declared === void 0 ? 0 : reader(declared); }; const applyDefaultEmbedRatio = (frame, group) => { if (isAudioOnlyGroup(group.embeds)) return; frame.style.aspectRatio = "16 / 9"; }; const applyEmbedFrameAspect = (frame, group, width, height) => { if (width > 0 && height > 0) { frame.style.aspectRatio = `${width} / ${height}`; return; } if (height > 0) { frame.style.height = `${height}px`; return; } applyDefaultEmbedRatio(frame, group); }; const applyEmbedFrameSize = (frame, group) => { const width = readGroupDimension(group, readEmbedWidth); const height = readGroupDimension(group, readEmbedHeight); if (width > 0) frame.style.width = `min(100%, ${width}px)`; applyEmbedFrameAspect(frame, group, width, height); }; const addEmbedPage = (target, group) => { const clone = group.source.cloneNode(true); removeUnavailableEmbedClones(clone, group.source); const frame = document.createElement("div"); frame.className = "hbu-reader-embed"; frame.append(clone); applyEmbedFrameSize(frame, group); const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "embed"; page.append(frame); target.append(page); for (const embed of group.embeds) suspendNativeEmbed(embed); }; const addTextPages = (target, lines) => { let buffer = []; let bufferChars = 0; const flush = () => { const paragraphs = buffer; buffer = []; bufferChars = 0; if (paragraphs.length === 0) return; const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "text"; const copy = document.createElement("div"); copy.className = "hbu-reader-copy"; for (const paragraph of paragraphs) { const node = document.createElement("p"); node.textContent = paragraph; copy.append(node); } page.append(copy); target.append(page); }; for (const raw of lines) { const line = raw.replace(/\s+/g, " ").trim(); if (line === "") continue; buffer.push(line); bufferChars += line.length; if (buffer.length >= 4 || bufferChars >= 420) flush(); } flush(); }; const addMediaPage = (target, source, caption) => { if (source === "") return; const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "media"; const img = document.createElement("img"); img.className = "hbu-reader-media"; img.src = source; img.alt = caption || "正文图片"; img.loading = "lazy"; img.decoding = "async"; img.addEventListener("click", () => openLightbox(img.currentSrc || img.src)); page.append(img); target.append(page); }; const addNoticePage = (target, heading, message) => { const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "notice"; const card = document.createElement("div"); card.className = "hbu-reader-notice"; const title = document.createElement("h2"); title.textContent = heading; const copy = document.createElement("p"); copy.textContent = message; const action = document.createElement("a"); action.href = LINK_HREF_PREFIX + articleId; action.textContent = "查看原帖"; const capturedArticleId = articleId; action.addEventListener("click", (event) => { event.preventDefault(); if (articleId !== capturedArticleId) return; dismissedArticleId = capturedArticleId; teardown(); showToast("已退出沉浸阅读,可在设置中重新开启"); }); card.append(title, copy, action); page.append(card); target.append(page); }; const addVideoPage = (target, item) => { const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "video"; const video = document.createElement("video"); video.className = "hbu-reader-video"; video.controls = true; video.autoplay = false; video.playsInline = true; video.preload = "metadata"; video.setAttribute("aria-label", "原帖视频"); if (item.poster !== "") video.poster = item.poster; video.src = item.source; page.append(video); target.append(page); suspendNativeEmbed(item.node); }; const addVideoPostPages = (target, data) => { if (!data.isVideoPost) return; const usable = data.videos.filter((item) => item.source !== ""); if (usable.length === 0) { addNoticePage(target, "视频暂时无法加载", "原帖还没有给出可播放的视频地址,可以刷新页面重试,或回到原帖播放。"); return; } for (const item of usable) addVideoPage(target, item); }; const addHeadingPage = (target, text) => { const value = text.replace(/\s+/g, " ").trim(); if (value === "") return; const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "heading"; const copy = document.createElement("div"); copy.className = "hbu-reader-copy"; copy.textContent = value; page.append(copy); target.append(page); }; const addRichPage = (target, native) => { const clone = native.cloneNode(true); removeUnavailableEmbedClones(clone, native); bindRichProxy(clone, native); for (const group of readEmbedGroups(native)) { for (const embed of group.embeds) suspendNativeEmbed(embed); } const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "rich"; const rich = document.createElement("div"); rich.className = "hbu-reader-rich"; rich.append(clone); page.append(rich); target.append(page); }; const buildPages = (target, body) => { if (!(target instanceof DocumentFragment) || !(body instanceof Element)) return; const lines = []; const flush = () => { if (lines.length === 0) return; addTextPages(target, lines.splice(0, lines.length)); }; const visit = (node) => { if (node.nodeType === Node.TEXT_NODE) { for (const line of (node.textContent || "").split(/\n+/)) lines.push(line); return; } if (!(node instanceof Element)) return; const tag = node.tagName.toLowerCase(); if (tag === "br") { flush(); return; } if (tag === "script" || tag === "style") return; if (isHiddenEmbedNode(node)) return; if (tag === "img") { flush(); addMediaPage(target, node.getAttribute("data-src") || node.getAttribute("src") || "", node.getAttribute("alt") || ""); return; } if (/^h[1-4]$/.test(tag)) { flush(); addHeadingPage(target, node.textContent || ""); return; } const embedGroup = readEmbedGroup(node); if (embedGroup !== null) { flush(); addEmbedPage(target, embedGroup); return; } const images = Array.from(node.querySelectorAll("img")); const text = (node.textContent || "").replace(/\s+/g, " ").trim(); const mediaOnly = text === "" && (images.length === 1 || readVisibleEmbeds(node).length > 0); if (mediaOnly) { flush(); for (const item of readMediaSequence(node)) { if (item.kind === "image") { addMediaPage(target, item.node.getAttribute("data-src") || item.node.getAttribute("src") || item.node.currentSrc || "", item.node.getAttribute("alt") || ""); continue; } addEmbedPage(target, item.group); } return; } const className = typeof node.className === "string" ? node.className : ""; const isCard = /(^|\s)(com-|hb-cpt__image|card)/.test(className); const isMixedLeaf = text !== "" && !isCard && readVisibleEmbeds(node).length > 0 && node.querySelector("p, div, section, article, ul, ol, table, blockquote, pre, h1, h2, h3, h4, h5, h6") === null; if (["div", "section", "article"].includes(tag) && isMixedLeaf) { flush(); addRichPage(target, node); return; } if (["div", "section", "article"].includes(tag) && !isCard && node.children.length > 0) { for (const child of Array.from(node.childNodes)) visit(child); return; } const isSimple = (tag === "p" || tag === "div" && node.children.length === 0) && node.querySelector(EMBED_SELECTOR) === null; if (isSimple && text !== "" && images.length === 0) { lines.push(text); return; } flush(); if (text !== "" || images.length > 0) addRichPage(target, node); }; for (const child of Array.from(body.childNodes)) visit(child); flush(); }; const readEmbedSourceSrcs = (node) => JSON.stringify( Array.from(node.querySelectorAll("source")).map((child) => child.getAttribute("src") || "") ); const readEmbedStyleValue = (node, name) => node instanceof HTMLElement ? node.style[name] || "" : ""; const readEmbedSignature = (root) => { if (!(root instanceof Element)) return ""; return Array.from(root.querySelectorAll(".iframe, iframe, video, audio, embed, object")).map((node) => [ node.tagName.toLowerCase(), node.classList.contains("iframe") ? "wrapper" : "", readEmbedStyleValue(node, "width"), readEmbedStyleValue(node, "height"), readEmbedStyleValue(node, "visibility"), readEmbedStyleValue(node, "display"), node.getAttribute("width") || "", node.getAttribute("height") || "", node.getAttribute("src") || "", node instanceof HTMLMediaElement ? node.currentSrc : "", node.getAttribute("poster") || "", node.getAttribute("srcdoc") || "", node.getAttribute("data") || "", readEmbedSourceSrcs(node), node.hasAttribute("hidden") ? "hidden" : "", isUnavailableEmbed(node) ? "hidden" : "visible" ].join("~")).join("|"); }; const flushReaderRatio = () => { if (ratioSaveTimer !== 0) { window.clearTimeout(ratioSaveTimer); ratioSaveTimer = 0; } if (pendingRatio === null) return; writeReaderRatio(pendingRatio.id, pendingRatio.ratio); pendingRatio = null; }; const scheduleReaderRatio = (id, ratio) => { if (id === "") return; pendingRatio = { id, ratio }; if (ratioSaveTimer !== 0) return; ratioSaveTimer = window.setTimeout(flushReaderRatio, 400); }; const updateProgress = () => { const scroll = progressScroll; if (scroll === null || !scroll.isConnected) return; const max = scroll.scrollHeight - scroll.clientHeight; const ratio = max <= 0 ? 1 : Math.min(1, Math.max(0, scroll.scrollTop / max)); const percent = Math.round(ratio * 100); if (progressCurrent !== null && progressCurrent.textContent !== percent + "%") { progressCurrent.textContent = percent + "%"; } if (progressFill !== null && lastProgressPercent !== percent) { progressFill.style.width = Math.max(2, ratio * 100) + "%"; lastProgressPercent = percent; } if (!restoring) scheduleReaderRatio(articleId, ratio); }; const setActive = (index) => { activeIndex = Math.min(pageCount - 1, Math.max(0, index)); updateProgress(); writeReaderScroll(articleId, activeIndex); }; const observePages = (scroll) => { observer?.disconnect(); if (host === null) return; const pages = Array.from(host.querySelectorAll(".hbu-reader-page")); observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; const index = entry.target instanceof HTMLElement ? pages.indexOf(entry.target) : -1; if (index !== -1) setActive(index); } }, { root: scroll, threshold: 0.55 }); for (const page of pages) observer.observe(page); }; const closeCurrentReader = () => { dismissedArticleId = articleId; teardown(); showToast("已退出当前文章的沉浸阅读"); }; const syncReaderEndActions = () => { if (host === null) return; const status = navigation.readStatus(); const prevButton = host.querySelector(".hbu-reader-end-actions [data-prev]"); const nextButton = host.querySelector(".hbu-reader-end-actions [data-next]"); const statusNode = host.querySelector(".hbu-reader-end-status"); if (prevButton instanceof HTMLButtonElement) prevButton.disabled = status.prev === null; if (nextButton instanceof HTMLButtonElement) { nextButton.disabled = status.discovering; nextButton.innerHTML = status.discovering ? "正在寻找下一篇…" : "下一篇 " + READER_ICONS.next; } if (statusNode instanceof HTMLElement) { const text = status.discovering ? "正在从信息流寻找下一篇…" : status.error !== "" ? status.error + ",点击“下一篇”重试" : status.exhausted ? "已经读到本次信息流的末尾" : ""; statusNode.textContent = text; statusNode.hidden = text === ""; } }; const lockReaderAdvance = () => { readerAdvanceLockToken += 1; readerAdvanceLocked = true; return readerAdvanceLockToken; }; const unlockReaderAdvance = (token = 0) => { if (token !== 0 && token !== readerAdvanceLockToken) return; readerAdvanceLocked = false; }; const clearPendingReaderAdvance = () => { readerPendingAdvanceSourceId = ""; readerPendingAdvanceRevision = -1; }; const openReaderArticle = (id) => { if (id === "") return; const token = lockReaderAdvance(); void navigateToPath(LINK_HREF_PREFIX + id).then((accepted) => { if (accepted !== true) unlockReaderAdvance(token); }, () => { unlockReaderAdvance(token); }); }; const advanceFromReader = () => { if (host === null || readerAdvanceLocked) return; const status = navigation.readStatus(); if (status.next !== null && status.next.source !== "related") { const taken = navigation.takeNext(); if (taken !== null) { clearPendingReaderAdvance(); openReaderArticle(taken.id); return; } } readerPendingAdvanceSourceId = status.currentId; readerPendingAdvanceRevision = status.revision; void navigation.discover(); }; const createHead = (data) => { const head = document.createElement("div"); head.className = "hbu-reader-head"; const back = document.createElement("button"); back.type = "button"; back.className = "hbu-reader-round"; back.setAttribute("aria-label", "返回"); back.title = "返回"; back.innerHTML = READER_ICONS.back; back.addEventListener("click", () => history.back()); const home = document.createElement("button"); home.type = "button"; home.className = "hbu-reader-home"; home.setAttribute("aria-label", "返回社区首页"); home.title = "返回社区首页(H)"; home.innerHTML = READER_ICONS.home + "社区首页"; home.addEventListener("click", () => navigateToPath("/app/bbs/home")); const author = document.createElement("a"); author.className = "hbu-reader-head-author"; author.href = data.authorHref; author.title = data.author; if (data.avatar !== "") { const avatar = document.createElement("img"); avatar.src = data.avatar; avatar.alt = ""; author.append(avatar); } const name = document.createElement("span"); name.textContent = data.author; author.append(name); const follow = document.createElement("button"); follow.type = "button"; follow.className = "hbu-reader-follow"; follow.textContent = "关注"; follow.addEventListener("click", toggleFollowAuthor); const spacer = document.createElement("span"); spacer.className = "hbu-reader-head-spacer"; const share = document.createElement("button"); share.type = "button"; share.className = "hbu-reader-round"; share.setAttribute("aria-label", "分享"); share.title = "分享"; share.innerHTML = READER_ICONS.share; share.addEventListener("click", () => void shareArticle(data)); const exit = document.createElement("button"); exit.type = "button"; exit.className = "hbu-reader-round"; exit.setAttribute("aria-label", "关闭当前文章的沉浸阅读"); exit.title = "关闭当前文章的沉浸阅读(R)"; exit.innerHTML = READER_ICONS.close; exit.addEventListener("click", closeCurrentReader); head.append(back, home, author, follow, spacer, share, exit); return head; }; const createRail = (data) => { const rail = document.createElement("aside"); rail.className = "hbu-reader-rail"; rail.setAttribute("aria-label", "文章互动"); const avatar = document.createElement("a"); avatar.className = "hbu-reader-action hbu-reader-rail-avatar-link"; avatar.href = data.authorHref; avatar.title = "查看作者 " + data.author; avatar.setAttribute("aria-label", "查看作者 " + data.author); if (data.avatar !== "") { const img = document.createElement("img"); img.src = data.avatar; img.alt = ""; img.className = "hbu-reader-rail-avatar"; avatar.append(img); } const like = document.createElement("button"); like.type = "button"; like.className = "hbu-reader-action"; like.dataset["action"] = "like"; like.setAttribute("aria-label", "点赞"); like.title = "点赞"; like.addEventListener("click", () => void toggleLike()); const favour = document.createElement("button"); favour.type = "button"; favour.className = "hbu-reader-action"; favour.dataset["action"] = "favour"; favour.setAttribute("aria-label", "收藏"); favour.setAttribute("aria-haspopup", "dialog"); favour.setAttribute("aria-expanded", "false"); favour.title = "收藏到收藏夹"; favour.addEventListener("click", () => openFavourMenu(rail, favour)); const cy = document.createElement("button"); cy.type = "button"; cy.className = "hbu-reader-action hbu-reader-action--cy"; cy.dataset["action"] = "cy"; cy.setAttribute("aria-label", "CY 0"); cy.title = "CY 0"; cy.innerHTML = 'CY0'; cy.addEventListener("click", openCyDialog); const comment = document.createElement("button"); comment.type = "button"; comment.className = "hbu-reader-action"; comment.dataset["action"] = "comment"; comment.setAttribute("aria-label", "查看评论"); comment.title = "查看评论(C)"; comment.addEventListener("click", () => void openComments()); const share = document.createElement("button"); share.type = "button"; share.className = "hbu-reader-action"; share.setAttribute("aria-label", "分享"); share.title = "分享"; share.innerHTML = READER_ICONS.share; share.addEventListener("click", () => void shareArticle(data)); const spacing = document.createElement("button"); spacing.type = "button"; spacing.className = "hbu-reader-action"; spacing.dataset["action"] = "spacing"; spacing.setAttribute("aria-label", "正文行距设置"); spacing.setAttribute("aria-haspopup", "true"); spacing.setAttribute("aria-expanded", "false"); spacing.title = "正文行距设置"; spacing.innerHTML = SETTINGS_ICON; spacing.addEventListener("click", () => openSpacingMenu(rail, spacing)); rail.append(avatar, like, favour, cy, comment, share, spacing); return rail; }; const createHeroCover = (page, data, sources, kind) => { const cover = document.createElement("div"); cover.className = "hbu-reader-cover"; const stage = document.createElement("div"); stage.className = "hbu-reader-cover-stage"; const progress = document.createElement("div"); progress.className = "hbu-reader-cover-progress"; progress.hidden = true; const glow = document.createElement("div"); glow.className = "hbu-reader-cover-glow"; const capturedArticleId = articleId; const capturedGeneration = readerGeneration; const images = []; const renderCoverPager = () => { const nav = stage.querySelectorAll(".hbu-reader-cover-nav"); for (const node of nav) node.hidden = coverTotal < 2; progress.textContent = ""; progress.hidden = coverTotal < 2; if (coverTotal < 2) return; const row = document.createElement("div"); row.className = "hbu-reader-cover-dots"; images.forEach((image, dotIndex) => { const dot = document.createElement("button"); dot.type = "button"; dot.setAttribute("aria-label", "封面 " + (dotIndex + 1)); dot.setAttribute("aria-current", String(dotIndex === coverIndex)); dot.addEventListener("click", () => setCover(dotIndex)); row.append(dot); }); progress.append(row); }; const dropFailedCover = (failed) => { if (host === null || !host.isConnected) return; if (articleId !== capturedArticleId || readerGeneration !== capturedGeneration) return; const index = images.indexOf(failed); if (index === -1) return; images.splice(index, 1); failed.remove(); if (index < coverIndex) coverIndex -= 1; coverTotal = images.length; coverIndex = Math.min(Math.max(0, coverIndex), Math.max(0, coverTotal - 1)); if (coverTotal === 0) { cover.remove(); if (kind === "header" && data.heroImage !== "") { createHeroCover(page, data, [data.heroImage], "body"); return; } page.dataset["cover"] = "none"; coverIndex = 0; return; } page.dataset["cover"] = kind; renderCoverPager(); setCover(coverIndex); }; stage.append(glow); sources.forEach((source, index) => { const img = document.createElement("img"); img.alt = index === 0 ? data.title : "封面 " + (index + 1); img.dataset["active"] = String(index === 0); if (index > 1) img.loading = "lazy"; img.addEventListener("error", () => dropFailedCover(img), { once: true }); img.addEventListener("load", () => { if (img.naturalWidth > 0 && img.naturalHeight > 0) return; dropFailedCover(img); }, { once: true }); img.addEventListener("click", () => openLightbox(readImageSource(img))); stage.append(img); images.push(img); img.src = source; }); page.dataset["cover"] = kind; coverTotal = images.length; coverIndex = 0; if (images.length > 0) glow.style.backgroundImage = 'url("' + sources[0].replace(/"/g, '\\"') + '")'; if (images.length > 1) { const prev = document.createElement("button"); prev.type = "button"; prev.className = "hbu-reader-cover-nav"; prev.dataset["side"] = "prev"; prev.setAttribute("aria-label", "上一张封面"); prev.innerHTML = READER_ICONS.previous; prev.addEventListener("click", () => setCover(coverIndex - 1)); const next = document.createElement("button"); next.type = "button"; next.className = "hbu-reader-cover-nav"; next.dataset["side"] = "next"; next.setAttribute("aria-label", "下一张封面"); next.innerHTML = READER_ICONS.next; next.addEventListener("click", () => setCover(coverIndex + 1)); stage.append(prev, next); } renderCoverPager(); cover.append(stage, progress); page.prepend(cover); }; const createHero = (data) => { const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "hero"; const heroes = data.covers.length > 0 ? data.covers : data.heroImage !== "" ? [data.heroImage] : []; const kind = data.covers.length > 0 ? "header" : "body"; if (heroes.length > 0) createHeroCover(page, data, heroes, kind); else page.dataset["cover"] = "none"; const caption = document.createElement("div"); caption.className = "hbu-reader-caption"; if (data.tags.length > 0 || data.time !== "") { const kicker = document.createElement("div"); kicker.className = "hbu-reader-kicker"; for (const tag of data.tags.slice(0, 3)) { const chip = document.createElement("span"); chip.textContent = tag; kicker.append(chip); } if (data.time !== "") { const chip = document.createElement("span"); chip.textContent = data.time + (data.ip !== "" ? " · " + data.ip : ""); kicker.append(chip); } caption.append(kicker); } const title = document.createElement("h1"); title.className = "hbu-reader-title"; title.textContent = data.title || "无标题"; const byline = document.createElement("div"); byline.className = "hbu-reader-byline"; const author = document.createElement("b"); author.textContent = data.author; const hint = document.createElement("span"); hint.textContent = "向下滑动阅读"; byline.append(author, hint); caption.append(title, byline); page.append(caption); return page; }; const createEndPage = (data) => { const page = document.createElement("section"); page.className = "hbu-reader-page"; page.dataset["kind"] = "end"; const card = document.createElement("div"); card.className = "hbu-reader-end-card"; const head = document.createElement("div"); head.className = "hbu-reader-end-head"; if (data.avatar !== "") { const avatar = document.createElement("img"); avatar.src = data.avatar; avatar.alt = ""; head.append(avatar); } const name = document.createElement("div"); name.className = "hbu-reader-end-name"; const label = document.createElement("b"); label.textContent = data.author; const meta = document.createElement("span"); meta.textContent = (data.level !== "" ? "Lv." + data.level : "") + (data.time !== "" ? (data.level !== "" ? " · " : "") + data.time : ""); name.append(label, meta); const follow = document.createElement("button"); follow.type = "button"; follow.className = "hbu-reader-follow"; follow.textContent = "关注"; follow.addEventListener("click", toggleFollowAuthor); head.append(name, follow); const stats = document.createElement("div"); stats.className = "hbu-reader-stats"; for (const item of [ { value: data.counts.like, label: "点赞" }, { value: data.counts.favour, label: "收藏" }, { value: data.counts.comment, label: "评论" } ]) { const box = document.createElement("div"); box.dataset["stat"] = item.label === "点赞" ? "like" : item.label === "收藏" ? "favour" : "comment"; const value = document.createElement("b"); value.textContent = item.value; const text = document.createElement("span"); text.textContent = item.label; box.append(value, text); stats.append(box); } card.append(head, stats); if (data.tags.length > 0) { const tags = document.createElement("div"); tags.className = "hbu-reader-tags"; for (const tag of data.tags) { const button = document.createElement("button"); button.type = "button"; button.className = "hbu-reader-tag"; button.textContent = tag; bindTag(button, tag); tags.append(button); } card.append(tags); } page.append(card); const visitedPostIds = readVisitedPostIds(articleId); const visibleRelated = data.related.filter((item) => !visitedPostIds.has(item.id)); if (visibleRelated.length > 0) { const related = document.createElement("div"); related.className = "hbu-reader-related"; const heading = document.createElement("h2"); heading.textContent = "继续阅读"; related.append(heading); for (const item of visibleRelated.slice(0, 4)) { const link = document.createElement("a"); link.href = LINK_HREF_PREFIX + item.id; link.textContent = item.title || "帖子 " + item.id; related.append(link); } page.append(related); } const actions = document.createElement("div"); actions.className = "hbu-reader-end-actions"; const homeButton = document.createElement("button"); homeButton.type = "button"; homeButton.dataset["home"] = ""; homeButton.innerHTML = READER_ICONS.home + " 社区首页"; homeButton.addEventListener("click", () => navigateToPath("/app/bbs/home")); const prevButton = document.createElement("button"); prevButton.type = "button"; prevButton.dataset["prev"] = ""; prevButton.innerHTML = READER_ICONS.previous + " 上一篇"; prevButton.addEventListener("click", () => { const prev = navigation.readStatus().prev; if (prev !== null) navigateToPath(LINK_HREF_PREFIX + prev.id); }); const nextButton = document.createElement("button"); nextButton.type = "button"; nextButton.dataset["next"] = ""; nextButton.innerHTML = "下一篇 " + READER_ICONS.next; nextButton.addEventListener("click", advanceFromReader); const topButton = document.createElement("button"); topButton.type = "button"; topButton.textContent = "回到封面"; topButton.addEventListener("click", () => scrollToPage(0)); actions.append(homeButton, prevButton, topButton, nextButton); page.append(actions); const status = document.createElement("p"); status.className = "hbu-reader-end-status"; status.hidden = true; page.append(status); return page; }; const readSource = () => { let pageRoot; let article; const cached = sourceCache; if (cached !== null && cached.articleId === articleId && cached.pageRoot.isConnected && cached.article.isConnected) { pageRoot = cached.pageRoot; article = cached.article; } else { const queriedRoot = document.querySelector("#page-bbs-link"); const queriedArticle = queriedRoot?.querySelector(READER_SOURCE_ROOT_SELECTOR) || null; if (!(queriedRoot instanceof Element) || !(queriedArticle instanceof Element)) { sourceCache = null; return null; } pageRoot = queriedRoot; article = queriedArticle; sourceCache = { articleId, pageRoot, article }; } const body = article.querySelector(READER_SOURCE_BODY_SELECTOR) || null; const titleNode = article.querySelector(READER_SOURCE_TITLE_SELECTOR) || null; const covers = readHeaderImages(pageRoot); const bodyImages = readOrderedImageSources(body || article); const isVideoPost = article.classList.contains("hb-bbs__video"); const videoNodes = isVideoPost ? Array.from(article.querySelectorAll(READER_SOURCE_VIDEO_SELECTOR)) : []; const videos = videoNodes.map((node) => ({ node, source: readVideoSource(node), poster: readPosterSource(node) })); const videoMissing = isVideoPost && videos.every((item) => item.source === ""); const tags = pageRoot.querySelectorAll(".link-tags__tag-item"); const related = pageRoot.querySelectorAll(".bbs-link__related-recommend a[href*='/app/bbs/link/'], .related-recommend__container a[href*='/app/bbs/link/']"); return { pageRoot, article, body, titleNode, covers, bodyImages, videos, videoMissing, isVideoPost, signature: [ articleId, isVideoPost ? "video" : "post", titleNode?.textContent?.length || 0, body?.childNodes.length || 0, (body?.textContent || "").length, covers.join(","), bodyImages.join(","), videos.map((item) => item.source + "~" + item.poster).join(","), tags.length, related.length, readEmbedSignature(pageRoot) ].join(":") }; }; const readHeroImage = (body) => { if (!(body instanceof Element)) return ""; const candidates = []; for (const [index, node] of Array.from(body.querySelectorAll("img")).entries()) { if (node.closest(READER_MEDIA_EXCLUDE_SELECTOR) !== null) continue; const source = readImageSource(node); if (source === "") continue; const naturalWidth = node.naturalWidth; const naturalHeight = node.naturalHeight; const rect = node.getBoundingClientRect(); const declaredWidth = Number(node.getAttribute("width")) || 0; const declaredHeight = Number(node.getAttribute("height")) || 0; const width = naturalWidth > 0 ? naturalWidth : rect.width > 0 ? rect.width : declaredWidth; const height = naturalHeight > 0 ? naturalHeight : rect.height > 0 ? rect.height : declaredHeight; if (width > 0 && width < 120) continue; candidates.push({ source, area: width * height, loaded: naturalWidth > 0, index }); } if (candidates.length === 0) return ""; candidates.sort((left, right) => Number(right.loaded) - Number(left.loaded) || right.area - left.area || left.index - right.index); const best = candidates[0]; return best === void 0 ? "" : best.source; }; const readData = (source) => { if (source === null) return null; const { pageRoot, article, body, titleNode, covers, videos, videoMissing, isVideoPost } = source; const authorNode = pageRoot.querySelector(".link-user__username, .page-header__username"); const authorLink = pageRoot.querySelector(".link-user__user-wrapper, .page-header__user-wrapper"); const avatarNode = authorLink?.querySelector("img") || pageRoot.querySelector(".page-header__user-wrapper img"); const tags = Array.from(pageRoot.querySelectorAll(".link-tags__tag-item")).map((node) => (node.textContent || "").trim()).filter((value) => value !== ""); const time = (pageRoot.querySelector(".link-data__time")?.textContent || "").trim(); const ip = (pageRoot.querySelector(".link-data__ip")?.textContent || "").trim().replace(/^·/, ""); const level = (pageRoot.querySelector(".link-user__level-tag")?.className.match(/hb-level-(\d+)/) || [])[1] || ""; const related = Array.from(pageRoot.querySelectorAll(".bbs-link__related-recommend a[href*='/app/bbs/link/'], .related-recommend__container a[href*='/app/bbs/link/']")).map((anchor) => { const match = /link\/(\d+)/.exec(anchor.getAttribute("href") || ""); const id = match === null ? void 0 : match[1]; if (id === void 0) return null; return { id, title: (anchor.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80) }; }).filter((item) => item !== null).filter((item, index, list) => list.findIndex((other) => other.id === item.id) === index); const title = (titleNode?.textContent || "").trim(); const meta = readPostMeta(pageRoot, title); if (meta === null) return null; return { article, body, title, author: (authorNode?.textContent || "").trim() || "作者", authorId: meta.authorId, authorHref: authorLink?.getAttribute("href") || "/app/user/profile/", avatar: avatarNode?.getAttribute("src") || "", topicIds: meta.topicIds, topics: meta.topics, meta, covers, // Keep a body fallback even when header images exist, so a header image that // fails to load can hand the hero over to the largest body image. heroImage: readHeroImage(body), videos, videoMissing, isVideoPost, tags, time, ip, level, related, counts: readCounts(), signature: source.signature }; }; const refreshReaderSignature = () => { const refreshedSource = readSource(); if (refreshedSource !== null) signature = refreshedSource.signature; }; const openLinkedComments = () => { if (/comment/i.test(location.hash)) { requestAnimationFrame(() => openComments()); } }; const settleRatioRestore = (node, ratio) => { const apply = () => { node.scrollTop = (node.scrollHeight - node.clientHeight) * ratio; }; apply(); let cancelled = false; const stop = () => { cancelled = true; restoring = false; }; for (const type of ["wheel", "touchstart", "pointerdown", "keydown"]) node.addEventListener(type, stop, { once: true, passive: true }); const images = Array.from(node.querySelectorAll("img")); const settle = Promise.race([ Promise.allSettled(images.map((image) => image.decode())), new Promise((resolve) => window.setTimeout(resolve, 1200)) ]); settle.then(() => { if (cancelled) return; apply(); restoring = false; updateProgress(); }); }; const restoreReaderPosition = (ratio, restore) => { if (ratio >= 0) { requestAnimationFrame(() => { const node = host?.querySelector(".hbu-reader-scroll"); if (node === null || node === void 0) return; settleRatioRestore(node, ratio); }); return; } if (restore <= 0) return; requestAnimationFrame(() => { const pages = host?.querySelectorAll(".hbu-reader-page"); pages?.[restore]?.scrollIntoView({ block: "start", behavior: "auto" }); setActive(restore); }); }; const render = (data) => { if (host === null) return; restoreSuspendedEmbeds(); readerGeneration += 1; observer?.disconnect(); window.cancelAnimationFrame(scrollRaf); scrollRaf = 0; flushReaderRatio(); resetReaderComments(); clearPendingReader(); host.textContent = ""; progressScroll = null; progressCurrent = null; progressFill = null; lastProgressPercent = -1; linkStateController?.abort(); linkStateController = null; commentLoadController?.abort(); commentLoadController = null; resetFavourPanel(); linkState = null; linkStatePromise = null; cyPublishedCount = 0; articleCommentPublishedCount = 0; actionBusy = false; cyBusy = false; articleCommentBusy = false; clearArticleComposers(); cyDialog = null; cyTextarea = null; cySubmit = null; cyStatus = null; spacingPanel = null; spacingButton = null; spacingInput = null; coverIndex = 0; coverTotal = 0; activeIndex = 0; host.append(createHead(data)); const scroll = document.createElement("div"); scroll.className = "hbu-reader-scroll"; scroll.tabIndex = 0; scroll.setAttribute("aria-label", "文章内容"); bindSwipe(scroll); scroll.append(createHero(data)); const pages = document.createDocumentFragment(); addVideoPostPages(pages, data); if (data.body instanceof Element) buildPages(pages, data.body); refreshReaderSignature(); scroll.append(pages); scroll.append(createEndPage(data)); const rail = createRail(data); host.append(scroll, rail); const progress = document.createElement("div"); progress.className = "hbu-reader-progress"; const current = document.createElement("span"); current.dataset["current"] = ""; current.textContent = "1"; const bar = document.createElement("span"); bar.className = "hbu-reader-progress-bar"; const fill = document.createElement("i"); bar.append(fill); const total = document.createElement("span"); total.dataset["total"] = ""; total.textContent = ""; const hint = document.createElement("span"); hint.className = "hbu-reader-hint"; hint.textContent = "滚轮 / ↑↓ 滚动"; progress.append(current, bar, total, hint); const toast = document.createElement("div"); toast.className = "hbu-reader-toast"; toast.setAttribute("aria-live", "polite"); host.append(progress, toast); progressScroll = scroll; progressCurrent = current; progressFill = fill; pageCount = host.querySelectorAll(".hbu-reader-page").length; observePages(scroll); scroll.addEventListener("scroll", () => { if (scrollRaf !== 0) return; scrollRaf = window.requestAnimationFrame(() => { scrollRaf = 0; updateProgress(); }); }, { passive: true }); syncActionStates(); lastActionSyncAt = performance.now(); void loadLinkState(); const ratio = readReaderRatio(articleId); const restore = ratio < 0 ? Math.min(pageCount - 1, readReaderScroll(articleId)) : 0; restoring = ratio >= 0; updateProgress(); if (ratio >= 0) writeReaderRatio(articleId, ratio); openLinkedComments(); restoreReaderPosition(ratio, restore); navigation.sync(articleId, { ...data.meta, id: articleId }, data.related); syncReaderEndActions(); }; const clearPendingTimer = () => { if (readerPendingTimer !== 0) { window.clearTimeout(readerPendingTimer); readerPendingTimer = 0; } }; const clearPendingReader = () => { clearPendingTimer(); readerPendingCard?.remove(); readerPendingCard = null; readerPendingArticleId = ""; readerPendingGeneration = -1; }; const showPending = () => { ensureShell(); if (host === null) return; signature = ""; if (readerPendingCard !== null) { if (readerPendingArticleId === articleId && readerPendingGeneration === readerGeneration) return; clearPendingReader(); } host.textContent = ""; const pending = document.createElement("div"); pending.className = "hbu-reader-pending"; const card = document.createElement("div"); card.className = "hbu-reader-pending-card"; const message = document.createElement("p"); message.className = "hbu-reader-pending-text"; message.textContent = "正在加载文章…"; const actions = document.createElement("div"); actions.className = "hbu-reader-pending-actions"; actions.hidden = true; const retry = document.createElement("button"); retry.type = "button"; retry.dataset["retry"] = ""; retry.textContent = "重试"; const original = document.createElement("button"); original.type = "button"; original.dataset["original"] = ""; original.textContent = "查看原帖"; actions.append(retry, original); card.append(message, actions); pending.append(card); host.append(pending); readerPendingCard = pending; readerPendingArticleId = articleId; readerPendingGeneration = readerGeneration; const capturedArticleId = articleId; const capturedGeneration = readerGeneration; retry.addEventListener("click", () => { if (host === null || !host.isConnected) return; if (articleId !== capturedArticleId || readerGeneration !== capturedGeneration) return; clearPendingReader(); sourceCache = null; signature = ""; sync(); }); original.addEventListener("click", () => { if (host === null || !host.isConnected) return; if (articleId !== capturedArticleId || readerGeneration !== capturedGeneration) return; clearPendingReader(); dismissedArticleId = capturedArticleId; teardown(); showToast("已退出沉浸阅读,可在设置中重新开启"); }); readerPendingTimer = window.setTimeout(() => { readerPendingTimer = 0; if (host === null || !host.isConnected) return; if (articleId !== capturedArticleId || readerGeneration !== capturedGeneration) return; if (!pending.isConnected) return; message.textContent = "这篇帖子加载得有点慢,可能是网络中断或站点校验未通过。"; actions.hidden = false; }, READER_PENDING_NOTICE_MS); }; const teardown = () => { restoreSuspendedEmbeds(); readerGeneration += 1; clearPendingReader(); clearPendingReaderAdvance(); readerAdvanceLocked = false; readerAdvanceLockToken += 1; observer?.disconnect(); observer = null; flushReaderRatio(); restoring = false; progressScroll = null; progressCurrent = null; progressFill = null; lastProgressPercent = -1; sourceCache = null; nativeActionArticle = null; nativeActionBox = null; nativeFavourButton = null; linkStateController?.abort(); linkStateController = null; commentLoadController?.abort(); commentLoadController = null; resetFavourPanel(); linkState = null; linkStatePromise = null; cyPublishedCount = 0; articleCommentPublishedCount = 0; actionBusy = false; cyBusy = false; cyDialog = null; cyTextarea = null; cySubmit = null; cyStatus = null; articleCommentBusy = false; clearArticleComposers(); document.body.classList.remove(READER_CLASS); if (host !== null) host.inert = false; commentPanel?.remove(); commentPanel = null; commentPanelList = null; commentPanelStatus = null; commentPanelStatusText = null; commentPanelRetry = null; commentPanelMeta = null; commentPanelMore = null; commentPanelClose = null; commentPanelRefresh = null; commentState = createCommentState(); spacingPanel = null; spacingButton = null; spacingInput = null; host?.remove(); host = null; lightbox?.remove(); lightbox = null; signature = ""; pageCount = 0; activeIndex = 0; }; const ensureShell = () => { ensureReaderStyle(); if (!document.body.classList.contains(READER_CLASS)) document.body.classList.add(READER_CLASS); if (host === null) { host = document.createElement("div"); host.id = READER_ID; host.setAttribute("role", "region"); host.setAttribute("aria-label", "沉浸阅读"); document.body.append(host); } }; const sync = () => { const match = /^\/app\/bbs\/link\/(\d+)/.exec(location.pathname); if (match === null) { dismissedArticleId = ""; if (host !== null || document.body.classList.contains(READER_CLASS)) teardown(); signature = ""; return; } if (!readReaderPreference()) { if (host !== null || document.body.classList.contains(READER_CLASS)) teardown(); signature = ""; return; } const nextArticleId = match[1]; if (dismissedArticleId === nextArticleId) { if (host !== null || document.body.classList.contains(READER_CLASS)) teardown(); signature = ""; return; } if (articleId !== nextArticleId) { dismissedArticleId = ""; unlockReaderAdvance(); flushReaderRatio(); sourceCache = null; nativeActionArticle = null; nativeActionBox = null; nativeFavourButton = null; } articleId = nextArticleId; const source = readSource(); if (source === null) { showPending(); return; } ensureShell(); if (signature === source.signature) { const now = performance.now(); if (now - lastActionSyncAt >= 1e3) { lastActionSyncAt = now; syncActionStates(); } return; } signature = source.signature; const data = readData(source); if (data === null) return; render(data); }; navigation.subscribe((status) => { if (host === null) { clearPendingReaderAdvance(); return; } syncReaderEndActions(); if (readerPendingAdvanceSourceId === "") return; if (status.currentId !== readerPendingAdvanceSourceId || status.revision !== readerPendingAdvanceRevision) { clearPendingReaderAdvance(); return; } if (status.discovering) return; const taken = navigation.takeNext(); clearPendingReaderAdvance(); if (taken !== null) { openReaderArticle(taken.id); } }); document.addEventListener(READER_EVENT, (event) => { const detail = event instanceof CustomEvent ? event.detail : void 0; if (readerToggle !== null) readerToggle.setAttribute("aria-checked", String(detail === true)); if (detail !== true) { teardown(); showToast("已退出沉浸阅读,可在设置中重新开启"); return; } dismissedArticleId = ""; signature = ""; sync(); }); document.addEventListener("keydown", (event) => { if (host === null || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return; if (event.key === "Escape") { if (cyDialog !== null) { event.preventDefault(); closeCyDialog(); } else if (spacingPanel !== null && !spacingPanel.hidden) { event.preventDefault(); closeSpacingMenu(); spacingButton?.focus({ preventScroll: true }); } else if (favourPanel !== null && !favourPanel.hidden) { event.preventDefault(); closeFavourMenu(true); } else if (isCommentPanelOpen() && articleReplyTarget !== null) { event.preventDefault(); clearArticleReplyTarget(true); } else if (isCommentPanelOpen()) { event.preventDefault(); closeComments(); } else if (lightbox !== null) { event.preventDefault(); lightbox.remove(); lightbox = null; } else if (isSiteAlertOpen()) { return; } else { event.preventDefault(); closeCurrentReader(); } return; } if (isSiteAlertOpen() || eventHitsTypingTarget(event)) return; if (isCommentPanelOpen() && commentPanel !== null && commentPanel.contains(event.target instanceof Node ? event.target : null)) return; if (event.key === "[") { event.preventDefault(); const prev = navigation.readStatus().prev; if (prev !== null) navigateToPath(LINK_HREF_PREFIX + prev.id); return; } if (event.key === "]") { event.preventDefault(); advanceFromReader(); return; } if (event.key === "h" || event.key === "H") { event.preventDefault(); navigateToPath("/app/bbs/home"); return; } if (event.key === "ArrowRight" && coverInView() && coverTotal > 1) { event.preventDefault(); setCover(coverIndex + 1); return; } if (event.key === "ArrowLeft" && coverInView() && coverTotal > 1) { event.preventDefault(); setCover(coverIndex - 1); return; } if (["ArrowDown", "PageDown", "j", "J", " "].includes(event.key)) { event.preventDefault(); scrollViewport(1); } else if (["ArrowUp", "PageUp", "k", "K"].includes(event.key)) { event.preventDefault(); scrollViewport(-1); } else if (event.key === "Home") { event.preventDefault(); scrollToPage(0); } else if (event.key === "End") { event.preventDefault(); scrollToPage(pageCount - 1); } else if (event.key === "c" || event.key === "C") { event.preventDefault(); if (isCommentPanelOpen()) closeComments(); else openComments(); } else if (event.key === "r" || event.key === "R") { event.preventDefault(); closeCurrentReader(); } }); registerPageSync(sync, 200, [PAGE_ROUTE.ARTICLE], "article"); window.addEventListener("popstate", () => { signature = ""; sync(); }); window.addEventListener("pagehide", flushReaderRatio); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flushReaderRatio(); }); sync(); } // scripts/heybox-up/src/ui/native-comments.ts var NATIVE_COMMENT_CONTAINER_SELECTOR = "#page-bbs-link .link-comment"; var NATIVE_COMMENT_LOAD_THRESHOLD = 240; var NATIVE_COMMENT_LOAD_COOLDOWN_MS = 1200; var LOAD_MORE_TEXT = /^(?:加载更多|查看更多|点击加载|更多评论)(?:\s*[((].*[))])?$/; var LOAD_MORE_EXCLUDED_SELECTOR = [ ".comment-children__load-all", ".link-comment__comment-children", "textarea", "input", "[contenteditable]:not([contenteditable='false'])", ".link-comment__reply" ].join(", "); function isNearCommentScrollEnd(metrics, threshold = NATIVE_COMMENT_LOAD_THRESHOLD) { if (!Number.isFinite(metrics.scrollHeight) || !Number.isFinite(metrics.scrollTop) || !Number.isFinite(metrics.clientHeight)) { return false; } return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold; } function shouldTriggerNativeCommentLoad(gate, metrics, now) { if (!isNearCommentScrollEnd(metrics)) return false; if (gate.lastTriggerAt > 0 && now - gate.lastTriggerAt < NATIVE_COMMENT_LOAD_COOLDOWN_MS) return false; return gate.lastTriggerHeight === 0 || metrics.scrollHeight > gate.lastTriggerHeight; } function isHiddenControl(node) { if (node.hasAttribute("hidden")) return true; if (node.getAttribute("aria-hidden") === "true") return true; if (node instanceof HTMLElement && node.style.display === "none") return true; return false; } function isDisabledControl(node) { if (node.hasAttribute("disabled")) return true; if (node.getAttribute("aria-disabled") === "true") return true; return false; } function readNativeLoadMoreButton(container) { for (const node of Array.from(container.querySelectorAll('button, a, [role="button"]'))) { if (node.closest(LOAD_MORE_EXCLUDED_SELECTOR) !== null) continue; if (isHiddenControl(node) || isDisabledControl(node)) continue; const label = (node.textContent ?? "").replace(/\s+/g, " ").trim(); if (!LOAD_MORE_TEXT.test(label)) continue; return node; } return null; } function requestNativeCommentLoad(container) { const button = readNativeLoadMoreButton(container); if (button !== null) { button.click(); return "button"; } window.dispatchEvent(new Event("scroll")); document.dispatchEvent(new Event("scroll")); return "scroll"; } function watchNativeComments() { if (!document.body) return; let bound = null; let boundScrollTop = 0; let frame = 0; const gate = { lastTriggerAt: 0, lastTriggerHeight: 0 }; const readContainer = () => { const node = document.querySelector(NATIVE_COMMENT_CONTAINER_SELECTOR); return node instanceof HTMLElement ? node : null; }; const unbind = () => { if (bound !== null) bound.removeEventListener("scroll", onScroll); bound = null; boundScrollTop = 0; if (frame !== 0) { cancelAnimationFrame(frame); frame = 0; } }; const run = () => { frame = 0; const container = bound; if (container === null || !container.isConnected) return; if (document.body.classList.contains(READER_CLASS)) return; boundScrollTop = container.scrollTop; const metrics = { scrollTop: container.scrollTop, clientHeight: container.clientHeight, scrollHeight: container.scrollHeight }; if (!shouldTriggerNativeCommentLoad(gate, metrics, performance.now())) return; const action = requestNativeCommentLoad(container); if (action === "none") return; gate.lastTriggerAt = performance.now(); gate.lastTriggerHeight = metrics.scrollHeight; }; function onScroll() { if (frame !== 0) return; frame = window.requestAnimationFrame(run); } const sync = () => { const container = readContainer(); const unavailable = container === null || !location.pathname.startsWith("/app/bbs/link/") || document.body.classList.contains(READER_CLASS) || container.clientHeight <= 0 || container.scrollHeight <= container.clientHeight; if (unavailable) { unbind(); return; } if (container === bound) { if (container.scrollTop < boundScrollTop - NATIVE_COMMENT_LOAD_THRESHOLD) { gate.lastTriggerAt = 0; gate.lastTriggerHeight = 0; } boundScrollTop = container.scrollTop; return; } unbind(); bound = container; boundScrollTop = container.scrollTop; gate.lastTriggerAt = 0; gate.lastTriggerHeight = 0; container.addEventListener("scroll", onScroll, { passive: true }); }; registerPageSync(sync, 400, [PAGE_ROUTE.ARTICLE], "article"); sync(); } // scripts/heybox-up/src/index.ts if (isOfficialHomePath(location.pathname)) { releaseTheme(); } else { THEME_MEDIA.addEventListener("change", () => { if (currentThemeMode === "system") applyTheme("system", false); }); applyTheme(readThemeMode(), false); applyFeedWidth(readFeedWidth()); applyReaderLineHeight(readReaderLineHeight()); } document.addEventListener(NAV_MOTION_EVENT, (event) => { const detail = event instanceof CustomEvent ? event.detail : void 0; applyNavMotionPreference(detail === true); }); if (!isOfficialHomePath(location.pathname)) applyNavMotionPreference(readNavMotionPreference()); function releaseOfficialHome() { if (!isOfficialHomePath(location.pathname)) return; releaseTheme(); releaseNav(); releaseHideCy(); } function boot() { if (isOfficialHomePath(location.pathname)) { releaseOfficialHome(); } else { applyHideCyPreference(readHideCyPreference()); } registerPageSync(releaseOfficialHome, 0, [PAGE_ROUTE.OFFICIAL_HOME]); const navigation = createPostNavigationController(); watchTheme(); watchTopNav(); watchCommentSort(); watchFavourPage(); watchCommentRail(); watchPostPager(navigation); watchReader(navigation); watchNativeComments(); watchCommunitySearch(); watchDesktopShell(); startPageSyncObserver(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot, { once: true }); } else { boot(); } })();