面向 LINUX DO 的沉浸式增强阅读器,支持父回复预览、消息/历史/收藏、原图灯箱、主题布局、请求限流、缓存与 DOM 渲染管理。
Verze ze dne
// ==UserScript== // @name Awesome LinuxDo Reader // @name:zh-CN 更流畅的 LinuxDo 阅读器 // @namespace https://github.com/sunbigfly/awesome-linuxdo-reader // @version 0.1.2 // @license MIT // @description 面向 LINUX DO 的沉浸式增强阅读器,支持父回复预览、消息/历史/收藏、原图灯箱、主题布局、请求限流、缓存与 DOM 渲染管理。 // @description:en An immersive LINUX DO reader with threaded context, community panels, image lightbox, layouts, request control, cache, and DOM rendering management. // @author sunbigfly // @homepageURL https://github.com/sunbigfly/awesome-linuxdo-reader // @supportURL https://github.com/sunbigfly/awesome-linuxdo-reader/issues // @match https://linux.do/* // @icon https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect connect.linux.do // @run-at document-start // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js // ==/UserScript== (function () { 'use strict'; const BASE = location.origin; const READER_VERSION = '0.1.2'; const HOST_PAGE_WINDOW = globalThis.unsafeWindow || window; const TOPIC_CACHE_TTL = 90 * 1000; const READ_THRESHOLD = 1500; const FLUSH_INTERVAL = 5000; const JUMP_HIGHLIGHT_DEFAULTS = Object.freeze({ color: '#0888cc', radius: 10, borderWidth: 1, rate: 0.8, count: 2, }); const JUMP_HIGHLIGHT_LIMITS = Object.freeze({ radius: Object.freeze({ min: 0, max: 24, step: 1, integer: true }), borderWidth: Object.freeze({ min: 0, max: 4, step: 1, integer: true }), rate: Object.freeze({ min: 0.5, max: 2, step: 0.1, integer: false }), count: Object.freeze({ min: 1, max: 6, step: 1, integer: true }), }); const JUMP_HIGHLIGHT_SETTING_FIELDS = Object.freeze([ { key: 'color', pref: 'jumpHighlightColor', label: '闪烁颜色', ariaLabel: '闪烁颜色', type: 'color', format: String, help: '选择跳转目标楼层的闪烁颜色;提示使用半透明底色避免遮住正文,并用同色细轮廓准确呈现所选颜色。选择时实时预览,确认后保存。' }, { key: 'radius', pref: 'jumpHighlightRadius', label: '圆角', ariaLabel: '闪烁圆角', format: (value) => `${value}px`, help: `控制闪烁背景的圆角,可在 ${JUMP_HIGHLIGHT_LIMITS.radius.min}–${JUMP_HIGHLIGHT_LIMITS.radius.max}px 之间调整。拖动时实时预览,确认后保存。` }, { key: 'borderWidth', pref: 'jumpHighlightBorderWidth', label: '边框宽度', ariaLabel: '闪烁边框宽度', format: (value) => `${value}px`, help: `控制闪烁轮廓的宽度,可在 ${JUMP_HIGHLIGHT_LIMITS.borderWidth.min}–${JUMP_HIGHLIGHT_LIMITS.borderWidth.max}px 之间调整;设为 0px 可关闭边框,颜色跟随闪烁颜色。拖动时实时预览,确认后保存。` }, { key: 'rate', pref: 'jumpHighlightRate', label: '速率', ariaLabel: '闪烁速率', format: (value) => `${value.toFixed(1)} 次/秒`, help: `控制每秒闪烁次数,可在 ${JUMP_HIGHLIGHT_LIMITS.rate.min}–${JUMP_HIGHLIGHT_LIMITS.rate.max} 次/秒之间调整;数值越大闪得越快。拖动时实时预览,确认后保存。` }, { key: 'count', pref: 'jumpHighlightCount', label: '次数', ariaLabel: '闪烁次数', format: (value) => `${value} 次`, help: `控制一次跳转连续闪烁多少次,可在 ${JUMP_HIGHLIGHT_LIMITS.count.min}–${JUMP_HIGHLIGHT_LIMITS.count.max} 次之间调整。拖动时实时预览,确认后保存。` }, ]); const LINUXDO_LOGO_URL = 'https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png'; const EXTERNAL_FONT_RENDERING_MARKER = 'fr-init-once'; const FONT_RENDERING_PROJECT_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/'; const FONT_RENDERING_LICENSE_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/blob/master/LICENSE'; const READER_FONT_RENDERING_DEFAULTS = (() => { const userAgent = navigator.userAgent || ''; const isGecko = /Firefox\//.test(userAgent); const isWebKit = /AppleWebKit\//.test(userAgent) && !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(userAgent); return Object.freeze({ stroke: isGecko ? 0.03 : isWebKit ? 0.05 : 0.015, shadow: isGecko ? 0.55 : isWebKit ? 0.45 : 0.75, macSmoothing: /Mac/.test(navigator.platform || ''), }); })(); const LDP_PREF_KEY = 'linuxdo-enhanced-reader:prefs'; const LDP_CONFIG_EXPORT_FORMAT = 'awesome-linuxdo-reader-settings'; const LDP_CONFIG_EXPORT_VERSION = 3; const LDP_HISTORY_KEY = 'linuxdo-enhanced-reader:history'; const LDP_TOPIC_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topics'; const LDP_TOPIC_NAV_ICON_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topic-nav-icons'; const LDP_USER_CARD_CACHE_KEY = 'linuxdo-enhanced-reader:cache:users'; const LDP_NOTIFICATION_CACHE_KEY = 'linuxdo-enhanced-reader:cache:notifications'; const LDP_SHARED_REQUEST_STATE_KEY = 'linuxdo-enhanced-reader:shared-request-state:v1'; const LDP_SHARED_REQUEST_CHANNEL = 'linuxdo-enhanced-reader:shared-request-flow:v1'; const LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge:v1'; const LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge-result:v1'; const LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge-seen:v1'; const LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM = 'ldp_challenge_return'; const LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL = 15 * 1000; const LDP_EMBEDDED_RELOAD_STATE_KEY = 'linuxdo-enhanced-reader:embedded-reload-state:v1'; const LDP_EMBEDDED_RELOAD_STATE_TTL = 30 * 1000; const LDP_AVATAR_CACHE_NAME = 'linuxdo-enhanced-reader:avatars:v1'; const LDP_EMOJI_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:emoji-images:v1'; const LDP_LIGHTBOX_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:lightbox-images:v1'; const LDP_RESPONSE_CACHE_DB_NAME = 'linuxdo-enhanced-reader:responses:v1'; const LDP_RESPONSE_CACHE_STORE = 'responses'; const PERSISTENT_CACHE_VERSION = 1; const TOPIC_SNAPSHOT_VERSION = 1; const TOPIC_SNAPSHOT_WRITE_DELAY = 750; const TOPIC_SNAPSHOT_WRITE_MAX_DELAY = 3000; const MINUTE = 60 * 1000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const LDP_AVATAR_CACHE_MAX_AGE = 90 * DAY; const LDP_EMOJI_IMAGE_CACHE_MAX_AGE = 90 * DAY; const LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE = 7 * DAY; const LDP_SCROLL_LOCK_CLASS = 'ldp-scroll-lock'; const NATIVE_BYPASS_PARAM = 'ldp_native'; const NATIVE_BYPASS_TAB_KEY = 'linuxdo-enhanced-reader:native-tab'; const cloudflareChallengeReturnOwner = location.pathname === '/topics/timings' ? new URLSearchParams(location.search).get(LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM) : ''; if (cloudflareChallengeReturnOwner) { window.addEventListener('storage', (event) => { if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return; let lease = null; try { lease = JSON.parse(event.newValue); } catch (e) {} if (lease && lease.owner === cloudflareChallengeReturnOwner && lease.state === 'passed') { try { window.close(); } catch (e) {} } }); const reportChallengePopupNavigation = () => { const challenge = !!( window._cf_chl_opt || document.querySelector( 'iframe[src*="challenges.cloudflare.com"],script[src*="/cdn-cgi/challenge-platform/"],#challenge-running' ) || /^(?:Just a moment|请稍候)/i.test(String(document.title || '')) ); if (challenge) { try { sessionStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY, cloudflareChallengeReturnOwner); } catch (e) {} return; } const navigation = performance && performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; let challenged = false; try { challenged = sessionStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY) === cloudflareChallengeReturnOwner; sessionStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY); } catch (e) {} const result = { owner: cloudflareChallengeReturnOwner, status: Math.max(0, Number(navigation && navigation.responseStatus) || 0), challenged, at: Date.now(), }; try { localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY, JSON.stringify(result)); } catch (e) {} }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', reportChallengePopupNavigation, { once: true }); } else { reportChallengePopupNavigation(); } return; } const KATEX_VERSION = '0.16.22'; const IMAGE_SCALE_OPTIONS = [25, 50, 100, 125, 150, 200]; const LIGHTBOX_DESCRIPTION_HEIGHT_MIN = 56; const LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT = 120; const LIGHTBOX_COMMENTS_WIDTH_DEFAULT = 25; const LIGHTBOX_COMMENTS_WIDTH_MIN = 18; const LIGHTBOX_COMMENTS_WIDTH_MAX = 50; const IMAGE_SCALE_MIN = 50; const IMAGE_SCALE_MAX = 200; const FONT_SCALE_MIN = 50; const FONT_SCALE_MAX = 250; const FONT_SCALE_DEFAULT = 100; const COMPOSER_FONT_SCALE_DEFAULT = 80; const READER_THEME_MODES = Object.freeze(['light', 'dark', 'system']); const READER_THEME_LABELS = Object.freeze({ light: '明', dark: '暗', system: '系统' }); const READER_THEME_VALUE_ROWS = [ ['--secondary', '#ffffff', '#171b20'], ['--primary', '#20252b', '#e7edf3'], ['--primary-high', '#333a42', '#cbd4dc'], ['--primary-medium', '#68737e', '#98a4af'], ['--primary-low-mid', '#b8c0c8', '#4a5560'], ['--primary-low', '#d7dde3', '#39414a'], ['--primary-very-low', '#f2f4f6', '#242a31'], ['--secondary-low', '#4b545d', '#363c43'], ['--secondary-medium', '#89929c', '#7f8790'], ['--secondary-high', '#dfe3e6', '#c9d0d8'], ['--secondary-very-high', '#f7f8f9', '#e3e8ed'], ['--tertiary', '#47855f', '#78c295'], ['--tertiary-low', '#dceee2', '#223a2c'], ['--header_background', '#ffffff', '#171b20'], ['--header_primary', '#20252b', '#e7edf3'], ['--header_primary-low', '#e7eaed', '#2a3037'], ['--header_primary-low-mid', '#b7bec6', '#4a5560'], ['--header_primary-medium', '#68737e', '#98a4af'], ['--header_primary-high', '#333a42', '#cbd4dc'], ['--highlight', '#fff0a8', '#6b5a16'], ['--highlight-low', '#fff8d8', '#302b18'], ['--highlight-medium', '#ffe47a', '#8a7420'], ['--highlight-high', '#806200', '#ffe47a'], ['--quaternary', '#d86645', '#f08a67'], ['--danger', '#c84545', '#ff7b72'], ['--danger-low', '#f7dddd', '#44262a'], ['--danger-low-mid', '#efb5b5', '#6b353b'], ['--success', '#47855f', '#78c295'], ['--success-low', '#dceee2', '#223a2c'], ['--love', '#dc6542', '#ff8a65'], ['--love-low', '#f7ddd5', '#472b25'], ['--d-link-color', '#2870b8', '#71b7ff'], ['--d-post-aside-background', '#f7f9fa', '#20262d'], ['--ldp-zebra-color', '#eafbea', '#1b2b21'], ['--ldp-reply-line-color', '#47855f', '#78c295'], ['--ldp-quote-line-color', '#d7d7d7', '#46505a'], ['--ldp-divider-line-color', '#e5e5e5', '#343b44'], ['--ldp-request-metric-pine', '#315f47', '#86b49a'], ['--ldp-request-metric-navy', '#365673', '#8eacc8'], ['--ldp-request-metric-plum', '#5e4b72', '#b09bc4'], ['--ldp-request-metric-bronze', '#72551f', '#c5a36d'], ['--ldp-request-metric-wine', '#743f47', '#c7929a'], ]; const READER_THEME_VARIABLES = Object.freeze(Object.fromEntries( ['light', 'dark'].map((mode, valueIndex) => [mode, Object.freeze(Object.fromEntries( READER_THEME_VALUE_ROWS.map(([name, ...values]) => [name, values[valueIndex]]), ))]), )); const ACCENT_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--tertiary']; const LINK_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--d-link-color']; const LEGACY_ZEBRA_COLOR_DEFAULT = '#f0fff0'; const ZEBRA_COLOR_DEFAULT = '#eafbea'; const LEGACY_LIST_ZEBRA_COLOR_DEFAULT = '#b0cfab'; const LIST_ZEBRA_COLOR_DEFAULT = '#e5e7eb'; const ZEBRA_RADIUS_MIN = 0; const ZEBRA_RADIUS_MAX = 16; const ZEBRA_RADIUS_DEFAULT = 10; const ZEBRA_RADIUS_STEP = 1; const REPLY_LINE_COLOR_DEFAULT = '#47855f'; const LINE_WIDTH_MIN = 0.5; const LINE_WIDTH_MAX = 4; const LINE_WIDTH_STEP = 0.5; const LEGACY_REPLY_LINE_WIDTH_DEFAULT = 1.5; const REPLY_LINE_WIDTH_DEFAULT = 1; const REPLY_LINE_RADIUS_MIN = 0; const REPLY_LINE_RADIUS_MAX = 16; const REPLY_LINE_RADIUS_DEFAULT = 15; const REPLY_LINE_RADIUS_STEP = 1; const QUOTE_LINE_COLOR_DEFAULT = '#d7d7d7'; const LEGACY_STRUCTURE_LINE_WIDTH_DEFAULT = 1; const QUOTE_LINE_WIDTH_DEFAULT = 0.5; const DIVIDER_LINE_COLOR_DEFAULT = '#e5e5e5'; const DIVIDER_LINE_WIDTH_DEFAULT = 0.5; const LEGACY_FLOOR_PREVIEW_COLOR_DEFAULT = '#9a5b13'; const LEGACY_FLOOR_PREVIEW_COLOR_DEFAULT_V2 = '#ec5809'; const FLOOR_PREVIEW_COLOR_DEFAULT = '#dc2626'; const FLOOR_PREVIEW_HEIGHT_MIN = 180; const FLOOR_PREVIEW_HEIGHT_MAX = 720; const FLOOR_PREVIEW_HEIGHT_DEFAULT = 300; const FLOOR_PREVIEW_HEIGHT_STEP = 10; const PERFORMANCE_SETTING_GROUPS = [ { id: 'main-request', title: '主楼层取数', description: '决定单次主信息流请求的规模;只改变每批楼层数,不绕过共享调度。', fields: [ { key: 'pageSize', pref: 'performancePageSize', presets: [16, 40, 48], limits: { min: 12, max: 64, step: 1, integer: true }, title: '每批请求主楼层', unit: '条', inputMode: 'numeric', description: '接近已取数据边缘时,按该数量提交楼层 ID;越大越少等待,但单次下载、解析与缓存写入更重。', help: '主信息流接近数据边缘时,一次向服务器提交多少个楼层 ID。它决定响应规模,不是 DOM 数量,也不绕过共享请求调度。' }, ], }, { id: 'dom', title: '主楼层 DOM 窗口', description: '只控制页面挂载与渲染;滚动换窗会回收远处楼层,不会自行请求楼层。', fields: [ { key: 'streamOverscanViewports', pref: 'performanceStreamOverscan', presets: [0.35, 1.25, 2], limits: { min: 0.25, max: 3, step: 0.05 }, title: '视口外挂载缓冲', unit: '屏', inputMode: 'decimal', description: '在可见区域前后额外挂载多少屏;快速滚动时会按方向临时前探,但仍受 DOM 上限约束。', help: '在当前屏幕前后额外挂载多少屏主楼层。只影响 DOM 与渲染,不发起楼层请求,并始终受同时挂载上限约束。' }, { key: 'streamMaxItems', pref: 'performanceStreamMaxItems', presets: [28, 72, 96], limits: { min: 24, max: 128, step: 1, integer: true }, title: '同时挂载主楼层上限', unit: '个', inputMode: 'numeric', description: '滚动、首次进入和跳楼期间持续生效的硬上限;窗口移走后远处楼层会卸载,重媒体正文可更早退回占位。', help: '滚动、首次进入和跳楼期间同时挂载的主楼层硬上限。远离窗口的楼层会从 DOM 卸载,必要时保留高度占位。' }, ], }, { id: 'nested', title: '楼中楼自动请求', description: '这是独立请求来源:父楼层进入触发范围后,会请求该楼层的直属回复。', fields: [ { key: 'nestedPrefetchViewports', pref: 'performanceNestedPrefetch', presets: [0.35, 1.25, 2], limits: { min: 0, max: 3, step: 0.05 }, title: '自动请求触发距离', unit: '屏', inputMode: 'decimal', description: '0 表示父楼层进入视口后请求;大于 0 会提前对应屏数。每个父楼层首批最多取 20 条直属回复,后续页只在翻页时请求。', help: '父楼层进入该距离后自动请求 /posts/:id/replies.json。0 仍会在进入视口时请求;大于 0 才会提前预取。' }, ], }, { id: 'request', title: '共享请求调度边界', description: '主题、主楼层、楼中楼、消息和用户资料 JSON 共用;实际运行值只会比配置更保守。', fields: [ { key: 'requestMaxConcurrent', pref: 'performanceRequestConcurrency', presets: [1, 3, 4], limits: { min: 1, max: 4, step: 1, integer: true }, title: '配置并发上限', unit: '路', inputMode: 'numeric', description: '允许值的天花板,不保证跑满;多实例取更严格值,宿主活动、限流和恢复探测会继续压低实际并发。', help: '阅读器 JSON 请求的配置天花板。多实例、宿主活动、限流窗口与恢复状态可让实际并发更低,不会让它更高。' }, { key: 'requestMinInterval', pref: 'performanceRequestInterval', presets: [220, 90, 80], limits: { min: 80, max: 500, step: 10, integer: true }, title: '配置启动间隔下限', unit: 'ms', inputMode: 'numeric', description: '正常状态下的最快启动间隔;共享窗口、宿主请求或 429 会自动延长,也不会在恢复后突发补发。', help: '正常状态下两次 JSON 请求启动的最快间隔。自适应调度可以延长它,且不会在队列恢复后突发追赶。' }, { key: 'requestRateTarget', pref: 'performanceRequestRateTarget', presets: [80, 80, 80], limits: { min: 50, max: 95, step: 1, integer: true }, title: '窗口预算目标占用', unit: '%', inputMode: 'numeric', description: '按已探测的 10 秒与 60 秒边界保留余量;只调节启动预算,不改变主楼层批量或楼中楼触发数量。', help: '使用已探测 10 秒与 60 秒预算的目标比例。它只调节启动节奏,不减少主楼层批次或楼中楼产生的请求。' }, ], }, ]; const PERFORMANCE_SETTING_FIELDS = PERFORMANCE_SETTING_GROUPS.flatMap((group) => group.fields); const PERFORMANCE_PRESETS = Object.freeze(Object.fromEntries( ['low', 'balanced', 'high'].map((name, valueIndex) => [name, Object.freeze(Object.fromEntries( PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.presets[valueIndex]]), ))]), )); const PERFORMANCE_LIMITS = Object.freeze(Object.fromEntries( PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, Object.freeze(field.limits)]), )); const PERFORMANCE_PREF_KEYS = Object.freeze(Object.fromEntries( PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.pref]), )); const LAYOUT_REGION_KEYS = Object.freeze(['left', 'main', 'gap', 'timeline', 'right']); const LAYOUT_REGION_LABELS = Object.freeze({ left: '左留白', main: '主帖', gap: '中间间距', timeline: '时间轴', right: '右留白', }); const LEGACY_LAYOUT_PREF_KEYS = Object.freeze({ left: 'layoutLeftRatio', main: 'layoutMainRatio', gap: 'layoutGapRatio', timeline: 'layoutTimelineRatio', right: 'layoutRightRatio', }); const READER_PROFILE_KEYS = Object.freeze(['popup', 'full', 'mobile']); const READER_PROFILE_LABELS = Object.freeze({ popup: '浮窗', full: '全屏', mobile: '移动' }); const COMPOSER_WINDOW_PROFILE_PREFIXES = Object.freeze({ popup: 'composerWindow', full: 'fullComposerWindow', mobile: 'mobileComposerWindow', }); const READER_PROFILE_SHARING_KEYS = Object.freeze(['image', 'font', 'layout', 'appearance']); const READER_PROFILE_SHARING_LABELS = Object.freeze({ image: '图片', font: '字体', layout: '布局', appearance: '外观' }); const EMBEDDED_MOBILE_MAX_ASPECT_RATIO = 3 / 4; const LAYOUT_PROFILE_DEFAULTS = Object.freeze({ popup: Object.freeze({ left: 10, main: 72, gap: 3, timeline: 10, right: 5 }), full: Object.freeze({ left: 20, main: 58, gap: 5, timeline: 9, right: 8 }), mobile: Object.freeze({ left: 10, main: 68, gap: 3, timeline: 12, right: 7 }), }); const FONT_FAMILY_DEFAULT = 'system'; const FONT_FAMILY_OPTIONS = Object.freeze({ site: Object.freeze({ label: '跟随站点', stack: 'inherit' }), system: Object.freeze({ label: '当前系统字体', stack: 'system-ui,sans-serif' }), cjkSans: Object.freeze({ label: '中文无衬线', stack: '"Noto Sans CJK SC","Microsoft YaHei","PingFang SC",system-ui,sans-serif' }), serif: Object.freeze({ label: '衬线', stack: '"Noto Serif CJK SC","Songti SC",SimSun,serif' }), monospace: Object.freeze({ label: '等宽', stack: 'ui-monospace,SFMono-Regular,Consolas,monospace' }), custom: Object.freeze({ label: '自定义本机字体', stack: '' }), }); const FONT_WEIGHT_DEFAULT = 400; const FONT_WEIGHT_OPTIONS = Object.freeze([300, 400, 500, 600]); const FONT_WEIGHT_LABELS = Object.freeze({ 300: '细', 400: '常规', 500: '中等', 600: '半粗' }); const HOST_EMBED_SIZE_MIN = 50; const HOST_EMBED_SIZE_MAX = 200; const HOST_EMBED_SIZE_DEFAULT = 100; const HOST_EMBED_SIZE_SETTINGS = Object.freeze({ title: Object.freeze({ pref: 'hostEmbeddedTitleScale', label: '标题', defaultScale: 105, properties: [['--ldp-host-topic-title-size', 15]] }), avatar: Object.freeze({ pref: 'hostEmbeddedAvatarScale', label: '头像', defaultScale: 70, properties: [['--ldp-host-topic-avatar-size', 32], ['--ldp-host-topic-avatar-size-medium', 24], ['--ldp-host-topic-avatar-size-small', 20]] }), stats: Object.freeze({ pref: 'hostEmbeddedStatsScale', label: '右下角信息', defaultScale: 105, properties: [['--ldp-host-topic-stats-size', 10], ['--ldp-host-topic-stats-label-size', 9], ['--ldp-host-topic-stats-row-offset', -4]] }), labelCard: Object.freeze({ pref: 'hostEmbeddedLabelCardScale', label: 'Label Card', defaultScale: 105, properties: [['--ldp-host-label-card-height', 22], ['--ldp-host-label-card-font-size', 11], ['--ldp-host-label-card-icon-size', 14], ['--ldp-host-label-card-gap', 3], ['--ldp-host-label-card-padding', 7]] }), }); const FONT_SCOPE_KEYS = Object.freeze(['interface', 'post', 'composer']); const FONT_SCOPE_CONFIG = Object.freeze({ interface: Object.freeze({ label: '界面', family: 'family', customFamily: 'customFamily', weight: 'weight', color: 'interfaceColor', scale: 'interface' }), post: Object.freeze({ label: '正文', family: 'postFamily', customFamily: 'postCustomFamily', weight: 'postWeight', color: 'postColor', scale: 'post' }), composer: Object.freeze({ label: '回复', family: 'composerFamily', customFamily: 'composerCustomFamily', weight: 'composerWeight', color: 'composerColor', scale: 'composer' }), }); const HOST_FONT_CONFIG = Object.freeze({ label: '宿主主题列表', family: 'hostFontFamily', customFamily: 'hostFontCustomFamily', weight: 'hostFontWeight', color: 'hostFontColor', }); const HOST_FONT_DEFAULTS = Object.freeze({ hostFontFamily: FONT_FAMILY_DEFAULT, hostFontCustomFamily: '', hostFontWeight: FONT_WEIGHT_DEFAULT, hostFontColor: '', }); const FONT_PICKER_SCOPE_KEYS = Object.freeze([...FONT_SCOPE_KEYS, 'host']); const FONT_PROFILE_VALUE_KEYS = Object.freeze([ 'family', 'customFamily', 'weight', 'interfaceColor', 'interface', 'postFamily', 'postCustomFamily', 'postWeight', 'postColor', 'post', 'composerFamily', 'composerCustomFamily', 'composerWeight', 'composerColor', 'composer', ]); const FONT_PROFILE_DEFAULTS = Object.freeze({ popup: Object.freeze({ family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 92, postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: 95, composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT, composerColor: '', composer: COMPOSER_FONT_SCALE_DEFAULT }), full: Object.freeze({ family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 100, postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: FONT_SCALE_DEFAULT, composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT, composerColor: '', composer: COMPOSER_FONT_SCALE_DEFAULT }), mobile: Object.freeze({ family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 90, postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: 95, composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT, composerColor: '', composer: 75 }), }); const IMAGE_PROFILE_DEFAULT = Object.freeze({ preset: '100', custom: 100 }); const IMAGE_PROFILE_DEFAULTS = Object.freeze(Object.fromEntries( READER_PROFILE_KEYS.map((profile) => [profile, IMAGE_PROFILE_DEFAULT]) )); const APPEARANCE_PROFILE_DEFAULT = Object.freeze({ accentColor: ACCENT_COLOR_DEFAULT, linkColor: LINK_COLOR_DEFAULT, zebraColor: ZEBRA_COLOR_DEFAULT, zebraRadius: ZEBRA_RADIUS_DEFAULT, listZebraColor: LIST_ZEBRA_COLOR_DEFAULT, replyLineColor: REPLY_LINE_COLOR_DEFAULT, replyLineWidth: REPLY_LINE_WIDTH_DEFAULT, replyLineRadius: REPLY_LINE_RADIUS_DEFAULT, quoteLineColor: QUOTE_LINE_COLOR_DEFAULT, quoteLineWidth: QUOTE_LINE_WIDTH_DEFAULT, dividerLineColor: DIVIDER_LINE_COLOR_DEFAULT, dividerLineWidth: DIVIDER_LINE_WIDTH_DEFAULT, floorPreviewColor: FLOOR_PREVIEW_COLOR_DEFAULT, floorPreviewHeight: FLOOR_PREVIEW_HEIGHT_DEFAULT }); const APPEARANCE_PROFILE_DEFAULTS = Object.freeze(Object.fromEntries( READER_PROFILE_KEYS.map((profile) => [profile, APPEARANCE_PROFILE_DEFAULT]) )); const APPEARANCE_PROFILE_VALUE_KEYS = Object.freeze([ 'accentColor', 'linkColor', 'zebraColor', 'zebraRadius', 'listZebraColor', 'replyLineColor', 'replyLineWidth', 'replyLineRadius', 'quoteLineColor', 'quoteLineWidth', 'dividerLineColor', 'dividerLineWidth', 'floorPreviewColor', 'floorPreviewHeight', ]); const READER_PROFILE_SHARING_FIELDS = Object.freeze({ image: Object.freeze(['scale']), font: Object.freeze(['family', 'weight', 'interfaceColor', 'interface', 'postFamily', 'postWeight', 'postColor', 'post', 'composerFamily', 'composerWeight', 'composerColor', 'composer']), layout: LAYOUT_REGION_KEYS, appearance: APPEARANCE_PROFILE_VALUE_KEYS, }); const READER_PROFILE_SHARING_DEFAULTS = Object.freeze(READER_PROFILE_SHARING_KEYS.reduce((result, category) => { result[category] = Object.freeze(READER_PROFILE_SHARING_FIELDS[category].reduce((fields, field) => { fields[field] = false; return fields; }, {})); return result; }, {})); const INTERFACE_FONT_TOKEN_BASES = Object.freeze({ '--ldp-font-micro': 9, '--ldp-font-xs': 10, '--ldp-font-sm': 11, '--ldp-font-ui': 12, '--ldp-font-base': 13, '--ldp-font-md': 14, '--ldp-font-lg': 15, '--ldp-font-xl': 16, '--ldp-font-2xl': 17, '--ldp-font-3xl': 18, }); const POST_FONT_BASE_PX = 14; const LAYOUT_MIN_RATIOS = Object.freeze({ left: 0, main: 40, gap: 0, timeline: 6, right: 0 }); const READER_WINDOW_DEFAULT_WIDTH = 1080; const READER_WINDOW_DEFAULT_VIEWPORT_WIDTH = 0.94; const READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT = 0.86; const READER_WINDOW_MIN_WIDTH = 360; const READER_WINDOW_MIN_HEIGHT = 320; const READER_WINDOW_COMPACT_WIDTH = 700; const READER_WINDOW_MARGIN = 8; const READER_WINDOW_HANDLE_DOCK_THRESHOLD = 38; const READER_EMBED_MIN_WIDTH = 360; const READER_EMBED_DEFAULT_WIDTH = 520; const READER_HOST_MIN_WIDTH = 680; const LIST_READER_MODES = Object.freeze(['floating', 'fullpage', 'embed-left', 'embed-right']); const TOPIC_READER_MODES = Object.freeze(['floating']); const BOOST_COPY_MAX_LENGTH = 16; const BOOST_COPY_MODES = Object.freeze(['counter', 'text']); const BOOST_COPY_DEFAULTS = Object.freeze({ mode: 'counter', prefix: '', counterMarker: '+', counterStep: 1, fixedSuffix: '', }); const BALANCED_PERFORMANCE = PERFORMANCE_PRESETS.balanced; const NOTIFICATION_PAGE_SIZE = 24; const NOTIFICATION_CACHE_FRESH_FOR = MINUTE; const LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR = 10 * MINUTE; const HISTORY_PAGE_SIZE = 20; const BOOKMARKS_PAGE_SIZE = 20; const GIVEN_REACTIONS_PAGE_SIZE = 20; const GIVEN_LIKES_PAGE_SIZE = 60; const USER_CARD_CACHE_FRESH_FOR = 24 * HOUR; const USER_FOLLOW_LIST_CACHE_FRESH_FOR = 5 * MINUTE; const USER_FOLLOW_LIST_CACHE_MAX_ENTRIES = 32; const NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES = 512; const USER_FOLLOW_LIST_PAGE_SIZE = 10; const NOTIFICATION_GROUPS = { all: { label: '全部', types: [], typeIds: [] }, replies: { label: '回复', types: ['mentioned', 'replied', 'quoted', 'posted', 'group_mentioned'], typeIds: [1, 2, 3, 9, 15] }, likes: { label: '点赞', types: ['liked', 'liked_consolidated', 'reaction'], typeIds: [5, 19, 25] }, messages: { label: '私信', types: ['private_message', 'invited_to_private_message', 'group_message_summary'], typeIds: [6, 7, 16] }, }; const PERSISTENT_CACHE_CONFIG = { history: { storageKey: LDP_HISTORY_KEY, maxAge: 365 * DAY }, topics: { maxAge: 180 * DAY, maxEntries: 8, storedMaxEntries: 64, storedMaxBytes: 64 * 1024 * 1024 }, topicNavIcons: { storageKey: LDP_TOPIC_NAV_ICON_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 512 }, users: { storageKey: LDP_USER_CARD_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 128, storedMaxEntries: 256, storedMaxBytes: 16 * 1024 * 1024, }, notifications: { storageKey: LDP_NOTIFICATION_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 32, storedMaxEntries: 64, storedMaxBytes: 8 * 1024 * 1024, }, responses: { maxAge: 365 * DAY, storedMaxEntries: 256, storedMaxBytes: 32 * 1024 * 1024 }, assets: { maxAge: 90 * DAY }, }; const CACHE_TYPES = [ { key: 'history', label: '浏览历史', help: '勾选“浏览历史”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层和查看时间;不会删除浏览器本身的访问历史。' }, { key: 'topics', label: '主题与楼层', help: '勾选“主题与楼层”后清理,会删除本机保存的主题元数据、楼层正文、楼层关系、分类标签图标和相关 API 响应;下次打开相关主题时会重新联网获取。' }, { key: 'users', label: '用户卡', help: '勾选“用户卡”后清理,会删除用户名、简介、徽章、用户组等资料快照和相关接口响应;头像图片仍归“头像与图片”单独管理。' }, { key: 'notifications', label: '消息', help: '勾选“消息”后清理,会删除通知分页、已读状态、跳转位置和发送者摘要;头像资源保留,也不会删除站点账号里的真实消息。' }, { key: 'responses', label: 'API 响应(含收藏与回应)', help: '勾选“API 响应(含收藏与回应)”后清理,会删除收藏列表、给出的回应与点赞、表情注册表和其他通用接口结果;不会撤销站点上的真实收藏或回应。' }, { key: 'assets', label: '头像与图片', help: '勾选“头像与图片”后清理,会分别删除头像、Boost 表情和实际查看过的原图资源;不会删除帖子中的线上图片。' }, ]; const BOOKMARK_TAB_TYPES = Object.freeze(['Reaction', 'Topic', 'Post']); const BOOKMARK_TAB_LABELS = Object.freeze({ Reaction: '回应', Topic: '帖子', Post: '楼层' }); const HISTORY_EDGE_TRIGGER_MIN = 0; const HISTORY_EDGE_TRIGGER_MAX = 15; const HISTORY_EDGE_TRIGGER_DEFAULT = 15; const READER_LOADING_ANIMATION_KEYS = Object.freeze([ 'portal', 'constellation', 'corridor', 'typewave', 'crystal', 'marginalia', 'chapters', 'quoteecho', 'footnotes', 'inkverse', ]); const DEFAULT_PREFS = { fullPage: true, imageProfiles: IMAGE_PROFILE_DEFAULTS, sharedImageProfile: IMAGE_PROFILE_DEFAULTS.full, lightboxOriginalByDefault: false, lightboxCommentsExpandedByDefault: true, lightboxDescriptionExpanded: true, lightboxDescriptionHeight: LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT, lightboxCommentsWidthPercent: LIGHTBOX_COMMENTS_WIDTH_DEFAULT, themeMode: 'system', fontRenderingEnabled: true, fontRenderingOnHost: true, ...HOST_FONT_DEFAULTS, hostEmbeddedTitleScale: HOST_EMBED_SIZE_SETTINGS.title.defaultScale, hostEmbeddedAvatarScale: HOST_EMBED_SIZE_SETTINGS.avatar.defaultScale, hostEmbeddedStatsScale: HOST_EMBED_SIZE_SETTINGS.stats.defaultScale, hostEmbeddedLabelCardScale: HOST_EMBED_SIZE_SETTINGS.labelCard.defaultScale, fontProfiles: FONT_PROFILE_DEFAULTS, sharedFontProfile: FONT_PROFILE_DEFAULTS.full, appearanceProfiles: APPEARANCE_PROFILE_DEFAULTS, sharedAppearanceProfile: APPEARANCE_PROFILE_DEFAULTS.full, profileSharing: READER_PROFILE_SHARING_DEFAULTS, ...performancePrefsPatch(BALANCED_PERFORMANCE, 'balanced'), layoutProfiles: LAYOUT_PROFILE_DEFAULTS, sharedLayoutProfile: LAYOUT_PROFILE_DEFAULTS.full, ...normalizeReaderWindowPreferenceGroup({}, 'readerWindow'), topicReaderMode: 'floating', listReaderMode: 'floating', listReaderEmbedWidth: READER_EMBED_DEFAULT_WIDTH, ...normalizeReaderWindowPreferenceGroup({}, 'listReaderWindow'), ...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PROFILE_PREFIXES.popup), ...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PROFILE_PREFIXES.full), ...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PROFILE_PREFIXES.mobile), historySortMode: 'recent-viewed', bookmarkTabOrder: BOOKMARK_TAB_TYPES, historyButtonsAlwaysVisible: false, historyEdgeTriggerPercent: HISTORY_EDGE_TRIGGER_DEFAULT, loadingAnimation: 'random', openTopicsAtFirstPost: false, expandNestedRepliesByDefault: true, expandLeafNestedReplies: false, ...jumpHighlightPrefsPatch(JUMP_HIGHLIGHT_DEFAULTS), boostCopyMode: BOOST_COPY_DEFAULTS.mode, boostCopyPrefix: BOOST_COPY_DEFAULTS.prefix, boostCopyCounterMarker: BOOST_COPY_DEFAULTS.counterMarker, boostCopyCounterStep: BOOST_COPY_DEFAULTS.counterStep, boostCopyFixedSuffix: BOOST_COPY_DEFAULTS.fixedSuffix, }; const bypassReaderForThisTab = consumeNativeBypassTabSignal() || shouldBypassReaderUrl(location.href); if (bypassReaderForThisTab) stripBypassParamFromUrl(); const INITIAL_TOPIC_ID = (extractTopicRouteFromUrl(location.href) || {}).topicId || null; let ME_USERNAME = null; let ME_CURRENT_USER = null; let ME_REQUEST = null; let CONNECT_TRUST_CACHE = null; let CONNECT_TRUST_REQUEST = null; const SHARED_ISSUE_FORBIDDEN_TOPIC_IDS = new Set(); let NOTIFICATION_UNREAD_COUNT = 0; let ACTIVE_READER_REQUEST_SCHEDULER = null; let SHARED_REQUEST_COORDINATOR = null; let READER_PORTAL_HOST = null; let READER_PORTAL_ROOT = null; let READER_PORTAL_CONTEXT_HTML = null; let READER_PORTAL_CONTEXT_BODY = null; const READER_HOST_STYLE_CLONES = new Map(); let READER_HOST_STYLE_OBSERVER = null; let READER_HOST_CONTEXT_OBSERVER = null; let READER_ICON_TOOLTIP_CLEANUP = null; if (INITIAL_TOPIC_ID) document.documentElement.classList.add('ldp-route-takeover'); // --- 楼中楼分批加载 & 请求节流 相关配置 --- const SUB_REPLY_PAGE_SIZE = 10; // 楼中楼分页每页条数 const SUB_REPLY_FETCH_SIZE = 20; // Discourse replies 接口单次返回上限 const REPLIES_HOVER_DELAY = 40; // 楼层真正进入视口并短暂停留后触发抓取(ms) const POST_REQUEST_MAX_429_RETRIES = 1; const POST_REQUEST_429_BACKOFF = 1500; const POST_REQUEST_TIMEOUT = 15000; const READER_REQUEST_QUEUE_LIMIT = 24; const READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES = 6; const READER_REQUEST_RATE_PROBE_SUCCESSES = 6; const READER_REQUEST_PROBE_RATE_STEP = 1.12; const READER_REQUEST_RECOVERY_PROBE_INTERVAL = 1200; const READER_REQUEST_RATE_MEMORY_TTL = 10 * 60 * 1000; const READER_ENDPOINT_429_EVIDENCE_WINDOW_MS = 4000; const READER_ENDPOINT_429_BASE_BLOCK_MS = 5000; const READER_ENDPOINT_429_MAX_BLOCK_MS = 5 * 60 * 1000; const READER_ENDPOINT_429_STATE_TTL = 10 * 60 * 1000; const READER_ENDPOINT_429_MAX_ENTRIES = 128; const READER_REQUEST_SHORT_WINDOW_MS = 10 * 1000; const READER_REQUEST_LONG_WINDOW_MS = 60 * 1000; const SHARED_REQUEST_POLICY_TTL = 30 * 1000; const SHARED_CACHE_FLIGHT_TTL = READER_REQUEST_LONG_WINDOW_MS + POST_REQUEST_TIMEOUT + 15000; const SHARED_CACHE_FLIGHT_STALE_MS = 35 * 1000; // Discourse defaults; each site may override them, so 429 feedback remains authoritative. const READER_REQUEST_DEFAULT_SHORT_CEILING = 50; const READER_REQUEST_DEFAULT_LONG_CEILING = 200; const REQUEST_FLOW_MAX_ENTRIES = 1200; const REQUEST_FLOW_RETENTION_MS = 15 * 60 * 1000; const REQUEST_FLOW_TRACE_WINDOW_MS = 10 * 1000; const REQUEST_FLOW_SLOW_MS = 1800; const REQUEST_FLOW_EVENTS = []; const REQUEST_FLOW_RESOURCE_DROPS = []; const REQUEST_FLOW_FETCH_META = Symbol('ldpRequestFlowFetchMeta'); const REQUEST_FLOW_XHR_STATE = new WeakMap(); let REQUEST_FLOW_RESOURCE_OBSERVER = null; const REQUEST_FLOW_TYPE_META = Object.freeze({ topic: Object.freeze({ label: '正文' }), nested: Object.freeze({ label: '楼中楼' }), avatar: Object.freeze({ label: '头像' }), media: Object.freeze({ label: '图片媒体' }), user: Object.freeze({ label: '用户资料' }), bookmark: Object.freeze({ label: '收藏' }), notification: Object.freeze({ label: '消息' }), realtime: Object.freeze({ label: '实时通道' }), presence: Object.freeze({ label: '在线状态' }), reaction: Object.freeze({ label: '回应操作' }), search: Object.freeze({ label: '搜索' }), read: Object.freeze({ label: '已读上报' }), asset: Object.freeze({ label: '静态资源' }), other: Object.freeze({ label: '其他' }), }); const REQUEST_FLOW_SOURCE_LABELS = Object.freeze({ reader: '阅读器', host: '宿主', browser: '资源' }); const REQUEST_FLOW_WAIT_REASON_LABELS = Object.freeze({ priority: '优先级', concurrency: '并发槽', cooldown: '429 退避', 'endpoint-cooldown': '端点熔断', interval: '启动间隔', '10s': '10 秒窗口', '60s': '60 秒窗口', }); const RESOURCE_MONITOR_SCOPE_LABELS = Object.freeze({ reader: '阅读器', host: '宿主/未标', shared: '页面共享' }); let REQUEST_FLOW_SEQUENCE = 0; function requestFlowNow() { const clock = globalThis.performance; if (clock && Number.isFinite(clock.timeOrigin) && typeof clock.now === 'function') { return clock.timeOrigin + clock.now(); } return Date.now(); } function requestFlowCallSite() { let stack = ''; try { stack = String(new Error().stack || ''); } catch (error) {} const internalFrame = /\b(?:requestFlowCallSite|beginRequestFlow|trackedFetch|trackedSend|waitForReaderRequestPermit|requestCachedJSON|requestJSON|fetchJSON|runTask|apiSend)\b/; const frame = stack.split('\n').map((line) => line.trim()).find((line, index) => index > 0 && line && !internalFrame.test(line) && !/\b(?:native code|Promise\.then)\b/.test(line)); if (!frame) return ''; return frame.replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => { const locationSuffix = raw.match(/(:\d+:\d+)$/)?.[1] || ''; const source = locationSuffix ? raw.slice(0, -locationSuffix.length) : raw; try { const url = new URL(source); return `${url.origin}${url.pathname}${locationSuffix}`; } catch (error) { return source.split(/[?#]/, 1)[0] + locationSuffix; } }).replace(/^at\s+/, '').slice(0, 220); } function requestFlowEventIndex(startedAt, id = -Infinity) { let low = 0; let high = REQUEST_FLOW_EVENTS.length; while (low < high) { const middle = Math.floor((low + high) / 2); const current = REQUEST_FLOW_EVENTS[middle]; if (current.startedAt < startedAt || (current.startedAt === startedAt && current.id <= id)) low = middle + 1; else high = middle; } return low; } function insertRequestFlowEvent(event) { const last = REQUEST_FLOW_EVENTS[REQUEST_FLOW_EVENTS.length - 1]; if (!last || last.startedAt < event.startedAt || (last.startedAt === event.startedAt && last.id <= event.id)) { REQUEST_FLOW_EVENTS.push(event); return; } REQUEST_FLOW_EVENTS.splice(requestFlowEventIndex(event.startedAt, event.id), 0, event); } function requestFlowUrl(input) { try { const value = input && typeof input === 'object' && input.url ? input.url : input; if (value == null || String(value) === '') return null; return new URL(String(value), location.href); } catch (error) { return null; } } function requestFlowPath(url) { if (!url) return '未知请求'; if (!['http:', 'https:'].includes(url.protocol)) return `${url.protocol.replace(':', '') || '本地'}资源`; const prefix = url.origin === location.origin ? '' : url.host; return `${prefix}${url.pathname || '/'}`.slice(0, 180); } function requestFlowType(url, initiatorType = '', method = 'GET') { const path = String(url && url.pathname || '').toLowerCase(); const host = String(url && url.hostname || '').toLowerCase(); const initiator = String(initiatorType || '').toLowerCase(); if (/\/posts\/\d+\/replies(?:\.json)?$/.test(path)) return 'nested'; if (/\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host)) return 'avatar'; if (/\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path)) return 'media'; if (/\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path)) return 'bookmark'; if (/\/notifications?(?:\/|\.|$)/.test(path)) return 'notification'; if (/\/message-bus(?:\/|$)/.test(path)) return 'realtime'; if (/\/presence\//.test(path)) return 'presence'; if (/\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path)) return 'search'; if (/topic-timings|\/timings(?:\/|\.|$)/.test(path)) return 'read'; if (/\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path)) return 'user'; if (/post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path)) return 'reaction'; if (/\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path)) return 'topic'; if (!['GET', 'HEAD'].includes(method)) return 'reaction'; if (['img', 'image', 'video', 'audio'].includes(initiator)) return 'media'; if (['css', 'link', 'script', 'font'].includes(initiator)) return 'asset'; return 'other'; } function pruneRequestFlowEvents(at = Date.now()) { const cutoff = at - REQUEST_FLOW_RETENTION_MS; let expired = 0; while (expired < REQUEST_FLOW_EVENTS.length && REQUEST_FLOW_EVENTS[expired].startedAt < cutoff) expired++; if (expired) REQUEST_FLOW_EVENTS.splice(0, expired); if (REQUEST_FLOW_EVENTS.length > REQUEST_FLOW_MAX_ENTRIES) { REQUEST_FLOW_EVENTS.splice(0, REQUEST_FLOW_EVENTS.length - REQUEST_FLOW_MAX_ENTRIES); } while (REQUEST_FLOW_RESOURCE_DROPS.length && REQUEST_FLOW_RESOURCE_DROPS[0].at < cutoff) { REQUEST_FLOW_RESOURCE_DROPS.shift(); } } function createRequestFlowEvent(url, details) { const { queuedAt, permittedAt, startedAt } = details; return { id: ++REQUEST_FLOW_SEQUENCE, ...details, endedAt: Number(details.endedAt) || 0, duration: 0, permitWait: Math.max(0, permittedAt - queuedAt), dispatchDuration: Math.max(0, startedAt - permittedAt), priority: Number.isFinite(details.priority) ? details.priority : null, recoveryProbe: details.recoveryProbe === true, attempt: Math.max(1, Math.floor(Number(details.attempt) || 1)), waitReason: String(details.waitReason || ''), callSite: details.callSite == null ? requestFlowCallSite() : String(details.callSite), type: details.type || requestFlowType(url, details.transport, details.method), path: String(details.path || requestFlowPath(url)), sameOrigin: !!url && url.origin === BASE, status: details.controlOnly ? 0 : null, size: 0, pending: details.controlOnly !== true, controlOnly: details.controlOnly === true, controlReason: String(details.controlReason || ''), rateLimitCode: '', retryAfter: '', serverLimit: '', serverRemaining: '', serverReset: '', error: '', resourceTimed: false, }; } function beginRequestFlow(input, init = {}, transport = 'fetch') { const url = requestFlowUrl(input); const method = String(init.method || (input && input.method) || 'GET').toUpperCase(); const meta = init && init[REQUEST_FLOW_FETCH_META] || {}; const startedAt = Number.isFinite(Number(meta.startedAt)) ? Number(meta.startedAt) : requestFlowNow(); const queuedAt = Number.isFinite(Number(meta.queuedAt)) ? Math.min(startedAt, Math.max(startedAt - REQUEST_FLOW_RETENTION_MS, Number(meta.queuedAt))) : startedAt; const permittedAt = Number.isFinite(Number(meta.permittedAt)) ? Math.min(startedAt, Math.max(queuedAt, Number(meta.permittedAt))) : startedAt; const event = createRequestFlowEvent(url, { queuedAt, permittedAt, startedAt, endedAt: meta.endedAt, method, transport, source: meta.source === 'reader' ? 'reader' : 'host', priority: meta.priority, recoveryProbe: meta.recoveryProbe, attempt: meta.attempt, waitReason: meta.waitReason, callSite: meta.callSite, type: String(meta.type || requestFlowType(url, meta.classificationTransport || transport, method)), controlOnly: meta.controlOnly, controlReason: meta.controlReason, path: meta.path, }); insertRequestFlowEvent(event); pruneRequestFlowEvents(event.startedAt); if (SHARED_REQUEST_COORDINATOR && !event.controlOnly) SHARED_REQUEST_COORDINATOR.recordHostStart(event); return event; } function recordRequestFlowControl(input, options = {}) { const endedAt = requestFlowNow(); return beginRequestFlow(input, { method: options.method, [REQUEST_FLOW_FETCH_META]: { ...options, source: 'reader', startedAt: endedAt, permittedAt: endedAt, endedAt, controlOnly: true, controlReason: options.controlReason || 'cancelled', classificationTransport: 'fetch', }, }, 'scheduler'); } const REQUEST_FLOW_RESPONSE_HEADERS = { rateLimitCode: ['Discourse-Rate-Limit-Error-Code', 'X-Discourse-Rate-Limit-Error-Code'], retryAfter: ['Retry-After'], serverLimit: ['RateLimit-Limit', 'X-RateLimit-Limit'], serverRemaining: ['RateLimit-Remaining', 'X-RateLimit-Remaining'], serverReset: ['RateLimit-Reset', 'X-RateLimit-Reset'], }; function requestFlowHeader(headers, names) { if (typeof headers === 'string') { const lines = headers.split(/\r?\n/); for (const name of names) { const prefix = `${name.toLowerCase()}:`; const line = lines.find((candidate) => candidate.toLowerCase().startsWith(prefix)); if (line) return line.slice(line.indexOf(':') + 1).trim(); } return ''; } if (!headers || typeof headers.get !== 'function') return ''; for (const name of names) { const value = headers.get(name); if (value != null && value !== '') return String(value); } return ''; } function requestFlowResponseMetadata(headers) { const contentLength = Number(requestFlowHeader(headers, ['Content-Length'])); return { size: Number.isFinite(contentLength) ? contentLength : 0, ...Object.fromEntries(Object.entries(REQUEST_FLOW_RESPONSE_HEADERS).map( ([key, names]) => [key, requestFlowHeader(headers, names)] )), }; } function requestFlowRateLimitNumber(value) { const match = String(value == null ? '' : value).match(/-?\d+(?:\.\d+)?/); if (!match) return NaN; const number = Number(match[0]); return Number.isFinite(number) ? number : NaN; } function requestFlowRateLimitResetDelay(value, at = Date.now()) { const raw = String(value == null ? '' : value).trim(); const number = Number(raw); if (!Number.isFinite(number)) { const resetAt = Date.parse(raw); return Number.isFinite(resetAt) ? Math.max(0, resetAt - at) : 0; } if (!(number > 0)) return 0; if (number > 1e12) return Math.max(0, number - at); if (number > at / 1000 - DAY / 1000) return Math.max(0, number * 1000 - at); return Math.max(0, number * 1000); } function rateLimitWindowFromCode(code) { const short = /10[_-]?(?:secs?|seconds?)/i.test(String(code || '')); const long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(code || '')); return { short, long, known: short || long, label: short && long ? '10s+60s' : short ? '10s' : long ? '60s' : 'unknown', }; } function requestFlowRateLimitPolicies(event) { const rawLimit = String(event && event.serverLimit || ''); if (!rawLimit) return []; const remainingParts = String(event && event.serverRemaining || '').split(','); const resetParts = String(event && event.serverReset || '').split(','); const rateWindow = rateLimitWindowFromCode(event && event.rateLimitCode); const byWindow = new Map(); rawLimit.split(',').forEach((part, index) => { const limit = requestFlowRateLimitNumber(part); if (!(limit > 0)) return; const explicitWindow = String(part).match(/(?:^|;)\s*w\s*=\s*"?(\d+)/i); const windowSeconds = explicitWindow ? Number(explicitWindow[1]) : 0; const resetDelay = requestFlowRateLimitResetDelay(resetParts[index] || resetParts[0]); let window = windowSeconds > 0 && windowSeconds <= 15 ? '10s' : windowSeconds > 15 && windowSeconds <= 90 ? '60s' : ''; if (explicitWindow && !window) return; if (!window) { const { short: hitShort, long: hitLong } = rateWindow; if (resetDelay > 90000 && !hitShort && !hitLong) return; window = hitShort && !hitLong ? '10s' : hitLong && !hitShort ? '60s' : limit <= READER_REQUEST_DEFAULT_SHORT_CEILING * 2 ? '10s' : '60s'; } const policy = { window, limit, remaining: requestFlowRateLimitNumber(remainingParts[index] || (index === 0 ? remainingParts[0] : '')), resetDelay, }; const previous = byWindow.get(window); if (!previous || policy.limit < previous.limit) byWindow.set(window, policy); }); return Array.from(byWindow.values()); } function finishRequestFlow(event, details = {}) { if (!event || !event.pending) return; event.endedAt = Number(details.endedAt) || requestFlowNow(); if (!event.resourceTimed) { event.duration = Number.isFinite(Number(details.duration)) ? Math.max(0, Number(details.duration)) : Math.max(0, event.endedAt - event.startedAt); event.size = Math.max(0, Number(details.size) || 0); } event.status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0; event.pending = false; event.rateLimitCode = String(details.rateLimitCode || ''); event.retryAfter = String(details.retryAfter || ''); event.serverLimit = String(details.serverLimit || ''); event.serverRemaining = String(details.serverRemaining || ''); event.serverReset = String(details.serverReset || ''); event.error = String(details.error || ''); if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.recordHostFinish(event); const hostApiEvent = sharedRequestApiEvent(event); const readerApiEvent = sharedRequestApiEvent(event, 'reader'); const successfulApiEvent = (hostApiEvent || readerApiEvent) && event.status >= 200 && event.status < 400; let schedulerState = null; if (SHARED_REQUEST_COORDINATOR && (successfulApiEvent || (hostApiEvent && event.status === 429))) { try { schedulerState = ACTIVE_READER_REQUEST_SCHEDULER && ACTIVE_READER_REQUEST_SCHEDULER.snapshot(); } catch (error) {} } if (successfulApiEvent && SHARED_REQUEST_COORDINATOR && event.serverLimit) { SHARED_REQUEST_COORDINATOR.noteServerLimits({ policies: requestFlowRateLimitPolicies(event), rateTarget: schedulerState ? schedulerState.requestRateTarget / 100 : 0.8, shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40, longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160, }); } if (successfulApiEvent && SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.noteSuccess({ host: hostApiEvent, recoveryProbe: readerApiEvent && event.recoveryProbe, shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40, longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160, }); } if (hostApiEvent && event.status === 429 && SHARED_REQUEST_COORDINATOR) { const retrySeconds = event.retryAfter.trim() ? Number(event.retryAfter) : NaN; const retryDate = !Number.isFinite(retrySeconds) && event.retryAfter ? Date.parse(event.retryAfter) : 0; const wait = Number.isFinite(retrySeconds) ? retrySeconds * 1000 : retryDate ? Math.max(0, retryDate - Date.now()) : POST_REQUEST_429_BACKOFF; SHARED_REQUEST_COORDINATOR.noteRateLimit({ wait: Math.max(1000, wait), window: rateLimitWindowFromCode(event.rateLimitCode).label, shortBudget: schedulerState ? schedulerState.shortWindowBudget : 40, longBudget: schedulerState ? schedulerState.longWindowBudget : 160, }); } } function finishResponseRequestFlow(event, responseUrl, status, headers) { const finalUrl = requestFlowUrl(responseUrl); if (finalUrl) { event.path = requestFlowPath(finalUrl); event.sameOrigin = finalUrl.origin === BASE; if (event.type === 'other') { event.type = requestFlowType(finalUrl, event.transport, event.method); } } finishRequestFlow(event, { status, ...requestFlowResponseMetadata(headers), }); return finalUrl; } function hostTopicResponseId(url) { if (!url || url.origin !== BASE) return 0; const match = url.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/); return match ? Number(match[1]) : 0; } function storeHostTopicResponse(event, url, payload) { if (!event || event.source !== 'host' || event.method !== 'GET') return; const topicId = hostTopicResponseId(url); const postStream = payload && payload.post_stream; if (!topicId || Number(payload && payload.id) !== topicId || !postStream || !Array.isArray(postStream.stream) || !Array.isArray(postStream.posts)) return; const key = String(topicId); const readerEntry = TOPIC_DATA_CACHE.get(key); if (readerEntry && readerEntry.scope === responseCacheAuthScope() && topicCacheEntryTimestamp(readerEntry) >= Number(event.startedAt || 0)) return; const hostEntry = HOST_TOPIC_DATA_CACHE.get(key); if (hostEntry && Number(hostEntry.startedAt || 0) > event.startedAt) return; HOST_TOPIC_DATA_CACHE.delete(key); HOST_TOPIC_DATA_CACHE.set(key, { topic: payload, at: Date.now(), startedAt: event.startedAt }); setCachedTopicData(topicId, payload, { fetchedAt: Date.now(), source: 'host' }); while (HOST_TOPIC_DATA_CACHE.size > HOST_TOPIC_CACHE_MAX_ENTRIES) { HOST_TOPIC_DATA_CACHE.delete(HOST_TOPIC_DATA_CACHE.keys().next().value); } } function captureHostTopicFetchResponse(event, response) { if (!response || !response.ok || event.source !== 'host' || event.method !== 'GET') return; const url = requestFlowUrl(response.url); const contentType = requestFlowHeader(response.headers, ['Content-Type']).toLowerCase(); if (!hostTopicResponseId(url) || !contentType.includes('json')) return; try { response.clone().json().then((payload) => storeHostTopicResponse(event, url, payload)).catch(() => {}); } catch (error) {} } function captureHostTopicXhrResponse(event, xhr, url) { if (!xhr || event.source !== 'host' || event.method !== 'GET' || xhr.status < 200 || xhr.status >= 300 || !hostTopicResponseId(url)) return; try { const contentType = String(xhr.getResponseHeader('Content-Type') || '').toLowerCase(); if (!contentType.includes('json')) return; const payload = xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText); storeHostTopicResponse(event, url, payload); } catch (error) {} } function recordResourceRequestFlow(entry) { const initiator = String(entry && entry.initiatorType || '').toLowerCase(); if (!entry) return; const url = requestFlowUrl(entry.name); if (!url || !['http:', 'https:'].includes(url.protocol)) return; const startedAt = performance.timeOrigin + Number(entry.startTime || 0); const duration = Math.max(0, Number(entry.duration) || 0); const size = Math.max(0, Number(entry.transferSize || entry.encodedBodySize) || 0); if (['fetch', 'xmlhttprequest'].includes(initiator)) { const path = requestFlowPath(url); let matchingRequest = null; for (let index = requestFlowEventIndex(startedAt - 100); index < REQUEST_FLOW_EVENTS.length; index++) { const event = REQUEST_FLOW_EVENTS[index]; const eventStartedAt = Number(event.startedAt); if (eventStartedAt > startedAt + 100) break; if (!Number.isFinite(eventStartedAt) || event.controlOnly || event.resourceTimed || event.transport !== initiator || event.path !== path) continue; if (!matchingRequest || Math.abs(eventStartedAt - startedAt) < Math.abs(matchingRequest.startedAt - startedAt)) { matchingRequest = event; } } if (matchingRequest) { Object.assign(matchingRequest, { resourceTimed: true, duration, size }); return; } } const event = createRequestFlowEvent(url, { queuedAt: startedAt, permittedAt: startedAt, startedAt, method: 'GET', transport: initiator || 'resource', source: 'browser', priority: null, callSite: initiator ? `${initiator} 资源加载` : '浏览器资源加载', type: requestFlowType(url, initiator, 'GET'), path: requestFlowPath(url), }); finishRequestFlow(event, { endedAt: performance.timeOrigin + Number(entry.responseEnd || entry.startTime || 0), duration, status: Number(entry.responseStatus) || 0, size, }); event.resourceTimed = true; insertRequestFlowEvent(event); } const SHARED_REQUEST_API_TRANSPORTS = Object.freeze({ host: ['fetch', 'xmlhttprequest'], reader: ['fetch'] }); function sharedRequestApiEvent(event, source = 'host') { return !!event && event.source === source && event.sameOrigin === true && SHARED_REQUEST_API_TRANSPORTS[source]?.includes(event.transport) === true && !['avatar', 'media', 'asset', 'realtime'].includes(event.type); } function createSharedRequestCoordinator() { const contextId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; const lockName = `${LDP_SHARED_REQUEST_CHANNEL}:lock`; const cacheLockName = `${LDP_SHARED_REQUEST_CHANNEL}:cache-lock`; const waiters = new Set(); const cacheListeners = new Set(); let sequence = 0; let destroyed = false; let channel = null; const emptyState = () => ({ version: 2, updatedAt: 0, events: [], intents: [], active: [], policies: [], flights: [], cacheEpoch: 0, shortBudget: 0, longBudget: 0, shortServerBudget: 0, longServerBudget: 0, cooldownUntil: 0, nextProbeAt: 0, shortServerBlockUntil: 0, longServerBlockUntil: 0, rateLimited: false, successfulSinceLimit: 0, lastRateLimitWindow: '', learnedUntil: 0, }); const requestPolicy = (source = {}, policyContextId = contextId) => ({ contextId: String(policyContextId), shortBudget: Math.max(1, Math.floor(Number(source.shortBudget) || READER_REQUEST_DEFAULT_SHORT_CEILING)), longBudget: Math.max(1, Math.floor(Number(source.longBudget) || READER_REQUEST_DEFAULT_LONG_CEILING)), minInterval: Math.max(0, Math.floor(Number(source.minInterval) || 0)), maxConcurrent: Math.max(1, Math.floor(Number(source.maxConcurrent) || 1)), }); const normalizeState = (raw, at = Date.now()) => { const state = raw && typeof raw === 'object' ? raw : emptyState(); state.version = 2; state.events = (Array.isArray(state.events) ? state.events : []) .map((event) => ({ at: Number(event && event.at) || 0 })) .filter((event) => event.at > at - READER_REQUEST_LONG_WINDOW_MS && event.at <= at) .sort((a, b) => a.at - b.at) .slice(-600); state.intents = (Array.isArray(state.intents) ? state.intents : []) .filter((intent) => intent && Number(intent.expiresAt) > at) .slice(-120); state.active = (Array.isArray(state.active) ? state.active : []) .filter((lease) => lease && Number(lease.expiresAt) > at) .slice(-32); state.policies = (Array.isArray(state.policies) ? state.policies : []) .filter((policy) => policy && policy.contextId && Number(policy.expiresAt) > at) .map((policy) => ({ ...requestPolicy(policy, policy.contextId), expiresAt: Number(policy.expiresAt), })) .slice(-32); state.flights = (Array.isArray(state.flights) ? state.flights : []) .map((flight) => ({ ...flight, heartbeatAt: Number(flight && flight.heartbeatAt) || Math.max(0, Number(flight && flight.expiresAt) - SHARED_CACHE_FLIGHT_TTL), })) .filter((flight) => flight && flight.token && Number(flight.expiresAt) > at && Number(flight.heartbeatAt) > at - SHARED_CACHE_FLIGHT_STALE_MS) .sort((a, b) => Number(a.expiresAt) - Number(b.expiresAt)) .slice(-120); state.cacheEpoch = Math.max(0, Math.floor(Number(state.cacheEpoch) || 0)); state.shortBudget = Math.max(0, Number(state.shortBudget) || 0); state.longBudget = Math.max(0, Number(state.longBudget) || 0); state.shortServerBudget = Math.max(0, Number(state.shortServerBudget) || 0); state.longServerBudget = Math.max(0, Number(state.longServerBudget) || 0); state.cooldownUntil = Math.max(0, Number(state.cooldownUntil) || 0); state.nextProbeAt = Math.max(0, Number(state.nextProbeAt) || 0); state.shortServerBlockUntil = Math.max(0, Number(state.shortServerBlockUntil) || 0); state.longServerBlockUntil = Math.max(0, Number(state.longServerBlockUntil) || 0); state.successfulSinceLimit = Math.max(0, Number(state.successfulSinceLimit) || 0); state.lastRateLimitWindow = String(state.lastRateLimitWindow || ''); state.learnedUntil = Math.max(0, Number(state.learnedUntil) || 0); if (state.learnedUntil <= at) { state.shortBudget = 0; state.longBudget = 0; state.shortServerBudget = 0; state.longServerBudget = 0; state.rateLimited = false; state.successfulSinceLimit = 0; state.lastRateLimitWindow = ''; } else state.rateLimited = state.rateLimited === true; return state; }; const readState = (at = Date.now()) => { try { return normalizeState(JSON.parse(localStorage.getItem(LDP_SHARED_REQUEST_STATE_KEY) || 'null'), at); } catch (error) { return emptyState(); } }; const notifyWaiters = () => { waiters.forEach((resolve) => resolve()); waiters.clear(); }; const publish = () => { try { if (channel) channel.postMessage({ type: 'updated', contextId }); } catch (error) {} notifyWaiters(); }; const writeState = (state, at = Date.now()) => { state.updatedAt = at; try { localStorage.setItem(LDP_SHARED_REQUEST_STATE_KEY, JSON.stringify(state)); } catch (error) {} publish(); }; const transact = async (operation) => { const execute = () => { const at = Date.now(); const state = readState(at); const result = operation(state, at); writeState(state, at); return result; }; const locks = navigator.locks; if (locks && typeof locks.request === 'function') { return locks.request(lockName, { mode: 'exclusive' }, execute); } return execute(); }; const withCacheLock = (operation) => { const locks = navigator.locks; if (locks && typeof locks.request === 'function') { return locks.request(cacheLockName, { mode: 'exclusive' }, operation); } return Promise.resolve().then(operation); }; const windowState = (state, at, requestedShortBudget, requestedLongBudget) => { const shortBudget = Math.max(1, Math.floor(Math.min( requestedShortBudget, state.shortBudget || requestedShortBudget, state.shortServerBudget || requestedShortBudget ))); const longBudget = Math.max(1, Math.floor(Math.min( requestedLongBudget, state.longBudget || requestedLongBudget, state.longServerBudget || requestedLongBudget ))); const shortEvents = state.events.filter((event) => event.at > at - READER_REQUEST_SHORT_WINDOW_MS); const longEvents = state.events; const delayFor = (events, budget, windowMs) => events.length < budget ? 0 : Math.max(0, events[events.length - budget].at + windowMs - at + 1); const shortDelay = delayFor(shortEvents, shortBudget, READER_REQUEST_SHORT_WINDOW_MS); const longDelay = delayFor(longEvents, longBudget, READER_REQUEST_LONG_WINDOW_MS); const shortServerDelay = Math.max(0, state.shortServerBlockUntil - at); const longServerDelay = Math.max(0, state.longServerBlockUntil - at); const effectiveShortDelay = Math.max(shortDelay, shortServerDelay); const effectiveLongDelay = Math.max(longDelay, longServerDelay); return { shortCount: shortEvents.length, longCount: longEvents.length, shortBudget, longBudget, serverDelay: Math.max(shortServerDelay, longServerDelay), delay: Math.max(effectiveShortDelay, effectiveLongDelay), blockingWindow: effectiveShortDelay >= effectiveLongDelay && effectiveShortDelay ? '10s' : effectiveLongDelay ? '60s' : '', }; }; const permitTiming = (state, at, policy) => { const windows = windowState(state, at, policy.shortBudget, policy.longBudget); const cooldownDelay = Math.max(0, state.cooldownUntil - at); const latestStart = state.events.length ? Number(state.events[state.events.length - 1].at) || 0 : 0; return { windows, cooldownDelay, intervalDelay: Math.max(0, latestStart + policy.minInterval - at) }; }; const rememberPolicy = (state, at, options = {}) => { const policy = { ...requestPolicy(options), expiresAt: at + SHARED_REQUEST_POLICY_TTL }; const index = state.policies.findIndex((candidate) => candidate.contextId === contextId); if (index >= 0) state.policies[index] = policy; else state.policies.push(policy); return policy; }; const effectivePolicy = (state, options = {}) => { const ownPolicy = requestPolicy(options); const policies = state.policies.filter((policy) => policy.contextId !== contextId); policies.push(ownPolicy); return { shortBudget: Math.min(...policies.map((policy) => policy.shortBudget)), longBudget: Math.min(...policies.map((policy) => policy.longBudget)), minInterval: Math.max(...policies.map((policy) => policy.minInterval)), maxConcurrent: Math.min(state.rateLimited ? 2 : Infinity, ...policies.map((policy) => policy.maxConcurrent)), }; }; const intentPriority = (intent, at) => { const ageBoost = Math.min(2, Math.floor(Math.max(0, at - intent.queuedAt) / 5000)); return Number(intent.priority) - ageBoost; }; const waitForUpdate = (delay, signal) => new Promise((resolve, reject) => { let settled = false; const settle = (callback) => { if (settled) return; settled = true; clearTimeout(timer); waiters.delete(finish); if (signal) signal.removeEventListener('abort', abort); callback(); }; const finish = () => settle(resolve); const abort = () => settle(() => reject(new DOMException('Aborted', 'AbortError'))); const timer = setTimeout(finish, Math.max(25, Math.min(1000, delay || 80))); waiters.add(finish); if (signal) signal.addEventListener('abort', abort, { once: true }); }); const cancelIntent = (intentId) => { void transact((state) => { state.intents = state.intents.filter((intent) => intent.id !== intentId); }); }; const acquirePermit = async (options = {}) => { const intentId = `${contextId}:intent:${++sequence}`; const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible; const signal = options.signal; let granted = false; let waitReason = ''; try { while (!destroyed && !(signal && signal.aborted)) { const result = await transact((state, at) => { rememberPolicy(state, at, options); const policy = effectivePolicy(state, options); const existing = state.intents.find((intent) => intent.id === intentId); if (existing) existing.expiresAt = at + 15000; else state.intents.push({ id: intentId, contextId, priority, queuedAt: at, expiresAt: at + 15000, }); state.intents.sort((a, b) => intentPriority(a, at) - intentPriority(b, at) || a.queuedAt - b.queuedAt); if (!state.intents.length || state.intents[0].id !== intentId) { return { granted: false, wait: 80, reason: 'priority' }; } if (state.active.length >= policy.maxConcurrent) { return { granted: false, wait: 80, reason: 'concurrency' }; } const { windows, cooldownDelay, intervalDelay } = permitTiming(state, at, policy); const canProbe = state.rateLimited && !cooldownDelay && !windows.serverDelay && windows.delay > 0 && !intervalDelay && priority <= POST_REQUEST_PRIORITY.visible && at >= state.nextProbeAt; const wait = Math.max(cooldownDelay, intervalDelay, canProbe ? 0 : windows.delay); if (wait > 0) { return { granted: false, wait, reason: cooldownDelay ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow, }; } state.intents.shift(); if (canProbe) state.nextProbeAt = at + READER_REQUEST_RECOVERY_PROBE_INTERVAL; state.events.push({ at }); const permitId = `${contextId}:permit:${++sequence}`; state.active.push({ id: permitId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 }); return { granted: true, permitId, startedAt: at, recoveryProbe: canProbe }; }); if (result.granted) { granted = true; return { ...result, waitReason }; } if (result.cancelled) { waitReason = String(result.reason || waitReason || 'cancelled'); throw new DOMException(waitReason, 'AbortError'); } waitReason = String(result.reason || waitReason); await waitForUpdate(result.wait, signal); } throw new DOMException('Aborted', 'AbortError'); } finally { if (!granted) cancelIntent(intentId); } }; const recordHostStart = (event) => { if (!sharedRequestApiEvent(event) || destroyed) return; const activeId = `${contextId}:host-active:${event.id}`; event.sharedRequestActiveId = activeId; event.sharedRequestStart = transact((state, at) => { const startedAt = Math.max(at - 5000, Math.min(at, Number(event.startedAt) || at)); state.events.push({ at: startedAt }); state.active.push({ id: activeId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 }); }); }; const finishPermit = (permitId) => { if (!permitId) return; void transact((state) => { state.active = state.active.filter((lease) => lease.id !== permitId); }); }; const recordHostFinish = (event) => { if (!event || !event.sharedRequestActiveId) return; Promise.resolve(event.sharedRequestStart).catch(() => {}).then(() => { finishPermit(event.sharedRequestActiveId); }); }; const acquireCacheFlight = (token) => transact((state, at) => { const normalizedToken = String(token || ''); if (!normalizedToken) return { producer: true, flightId: '' }; const existing = state.flights.find((flight) => flight.token === normalizedToken); if (existing) { return { producer: false, flightId: existing.id, expiresAt: existing.expiresAt, cacheEpoch: Number(existing.cacheEpoch) || 0, }; } const flightId = `${contextId}:flight:${++sequence}`; state.flights.push({ id: flightId, token: normalizedToken, contextId, cacheEpoch: state.cacheEpoch, heartbeatAt: at, expiresAt: at + SHARED_CACHE_FLIGHT_TTL, }); return { producer: true, flightId, expiresAt: at + SHARED_CACHE_FLIGHT_TTL, cacheEpoch: state.cacheEpoch, }; }); const releaseCacheFlight = (token, flightId) => { if (!token || !flightId) return Promise.resolve(); return transact((state) => { state.flights = state.flights.filter((flight) => flight.token !== String(token) || flight.id !== String(flightId)); }); }; const renewCacheFlight = (token, flightId) => { if (!token || !flightId) return Promise.resolve(false); return transact((state, at) => { const flight = state.flights.find((candidate) => candidate.token === String(token) && candidate.id === String(flightId)); if (!flight) return false; flight.heartbeatAt = at; flight.expiresAt = at + SHARED_CACHE_FLIGHT_TTL; return true; }); }; const writeCacheFlight = (token, flightId, cacheEpoch, operation) => withCacheLock(async () => { const state = readState(); const valid = state.cacheEpoch === Number(cacheEpoch || 0) && state.flights.some((flight) => flight.token === String(token) && flight.id === String(flightId)); if (!valid) return false; await operation(); return true; }); const invalidateCacheWrites = () => withCacheLock(() => transact((state) => { state.cacheEpoch++; return state.cacheEpoch; })); const waitForCacheFlight = async (token, signal, deadline = Date.now() + SHARED_CACHE_FLIGHT_TTL) => { const normalizedToken = String(token || ''); while (!destroyed && Date.now() < deadline && !(signal && signal.aborted)) { const state = readState(); const flight = state.flights.find((candidate) => candidate.token === normalizedToken); if (!flight) return true; await waitForUpdate(Math.min(1000, Math.max(25, flight.expiresAt - Date.now())), signal); } if (signal && signal.aborted) throw new DOMException('Aborted', 'AbortError'); return false; }; const noteRateLimit = (options = {}) => { void transact((state, at) => { const windows = windowState(state, at, options.shortBudget, options.longBudget); const limitWindow = String(options.window || 'unknown'); const hitShort = limitWindow === 'unknown' || limitWindow.includes('10s'); const hitLong = limitWindow === 'unknown' || limitWindow.includes('60s'); if (hitShort) { const safe = Math.max(1, Math.ceil(windows.shortCount * 0.9)); state.shortBudget = state.shortBudget ? Math.min(state.shortBudget, safe) : safe; } if (hitLong) { const safe = Math.max(1, Math.ceil(windows.longCount * 0.9)); state.longBudget = state.longBudget ? Math.min(state.longBudget, safe) : safe; } state.cooldownUntil = Math.max(state.cooldownUntil, at + Math.max(1000, Number(options.wait) || 0)); state.nextProbeAt = state.cooldownUntil; state.rateLimited = true; state.successfulSinceLimit = 0; state.lastRateLimitWindow = limitWindow; state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL; }); }; const resetRateLimits = () => { return transact((state) => { [ 'shortBudget', 'longBudget', 'shortServerBudget', 'longServerBudget', 'cooldownUntil', 'nextProbeAt', 'shortServerBlockUntil', 'longServerBlockUntil', 'successfulSinceLimit', 'learnedUntil', ].forEach((key) => { state[key] = 0; }); state.rateLimited = false; state.lastRateLimitWindow = ''; return true; }); }; const noteServerLimits = (options = {}) => { const rateTarget = Math.min(0.95, Math.max(0.5, Number(options.rateTarget) || 0.8)); const shortCap = Math.max(1, Math.floor(Number(options.shortBudgetCap) || 40)); const longCap = Math.max(1, Math.floor(Number(options.longBudgetCap) || 160)); const policies = (Array.isArray(options.policies) ? options.policies : []).map((policy) => { const window = policy && policy.window === '10s' ? '10s' : policy && policy.window === '60s' ? '60s' : ''; const limit = Math.max(0, Number(policy && policy.limit) || 0); const cap = window === '10s' ? shortCap : longCap; return { window, limit, remaining: Number(policy && policy.remaining), resetDelay: Math.max(0, Number(policy && policy.resetDelay) || 0), cap, desired: Math.max(1, Math.min(cap, Math.floor(limit * rateTarget))), }; }).filter((policy) => policy.window && policy.limit > 0); if (!policies.length) return; const previewAt = Date.now(); const preview = readState(previewAt); const needsUpdate = policies.some((policy) => { const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget'; const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil'; const current = Number(preview[budgetKey]) || 0; const effective = current || policy.cap; const budgetChanges = policy.desired < effective || (!preview.rateLimited && current > 0 && policy.desired > current); const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget))); const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0; const blockUntil = shouldBlock ? previewAt + policy.resetDelay : 0; const blockChanges = shouldBlock ? blockUntil > Number(preview[blockKey] || 0) + 500 : Number(preview[blockKey] || 0) > previewAt; return budgetChanges || blockChanges; }); if (!needsUpdate) return; void transact((state, at) => { let changed = false; policies.forEach((policy) => { const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget'; const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil'; const windowMs = policy.window === '10s' ? READER_REQUEST_SHORT_WINDOW_MS : READER_REQUEST_LONG_WINDOW_MS; const current = Number(state[budgetKey]) || 0; if (!current && policy.desired < policy.cap) { state[budgetKey] = policy.desired; changed = true; } else if (current && policy.desired < current) { state[budgetKey] = policy.desired; changed = true; } else if (!state.rateLimited && current && policy.desired > current) { const step = Math.max(1, Math.ceil((policy.desired - current) * 0.35)); const next = Math.min(policy.desired, current + step); state[budgetKey] = next >= policy.cap && policy.desired >= policy.cap ? 0 : next; changed = true; } const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget))); const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0; if (shouldBlock) { const blockUntil = at + Math.min(windowMs + 1000, policy.resetDelay); if (blockUntil > Number(state[blockKey] || 0) + 500) { state[blockKey] = blockUntil; changed = true; } } else if (Number(state[blockKey] || 0) > at) { state[blockKey] = 0; changed = true; } }); if (changed) state.learnedUntil = Math.max(state.learnedUntil, at + READER_REQUEST_RATE_MEMORY_TTL); }); }; const noteSuccess = (options = {}) => { const preview = readState(); if (!preview.rateLimited && !preview.shortBudget && !preview.longBudget) return; void transact((state, at) => { if (at < state.cooldownUntil || (!state.shortBudget && !state.longBudget)) return; const credit = options.recoveryProbe ? 2 : options.host ? 0.5 : 1; state.successfulSinceLimit += credit; const shouldGrow = state.rateLimited || state.successfulSinceLimit >= READER_REQUEST_RATE_PROBE_SUCCESSES; if (!shouldGrow) return; const shortCap = Math.max(1, Number(options.shortBudgetCap) || READER_REQUEST_DEFAULT_SHORT_CEILING); const longCap = Math.max(1, Number(options.longBudgetCap) || READER_REQUEST_DEFAULT_LONG_CEILING); const rateStep = options.recoveryProbe ? READER_REQUEST_PROBE_RATE_STEP : 1.04; if (state.shortBudget) { state.shortBudget = Math.min( shortCap, Math.max(state.shortBudget * rateStep, state.shortBudget + (options.recoveryProbe ? 2 : 1)) ); } if (state.longBudget) { state.longBudget = Math.min( longCap, Math.max(state.longBudget * rateStep, state.longBudget + (options.recoveryProbe ? 6 : 3)) ); } if (state.successfulSinceLimit >= READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES) { state.rateLimited = false; state.successfulSinceLimit = 0; } else if (!state.rateLimited) { state.successfulSinceLimit = 0; } const shortRecovered = !state.shortBudget || state.shortBudget >= shortCap; const longRecovered = !state.longBudget || state.longBudget >= longCap; if (!state.rateLimited && shortRecovered && longRecovered) { state.shortBudget = 0; state.longBudget = 0; } state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL; }); }; const snapshot = ( requestedShortBudget, requestedLongBudget, requestedMinInterval = 0, requestedMaxConcurrent = 1, at = Date.now() ) => { const state = readState(at); const policy = effectivePolicy(state, { shortBudget: requestedShortBudget, longBudget: requestedLongBudget, minInterval: requestedMinInterval, maxConcurrent: requestedMaxConcurrent, }); const { windows, cooldownDelay: cooldownRemaining, intervalDelay } = permitTiming(state, at, policy); const nextPermitDelay = Math.max(cooldownRemaining, intervalDelay, windows.delay); const liveContexts = new Set([ ...state.policies.map((entry) => entry.contextId), ...state.intents.map((entry) => entry.contextId), ...state.active.map((entry) => entry.contextId), ].filter(Boolean)); return { ...windows, queued: state.intents.length, active: state.active.length, instances: Math.max(1, liveContexts.size), maxConcurrent: policy.maxConcurrent, minInterval: policy.minInterval, coordinationMode: navigator.locks && typeof navigator.locks.request === 'function' ? 'atomic' : 'best-effort', coolingDown: cooldownRemaining > 0, cooldownRemaining, nextPermitDelay, blockingReason: cooldownRemaining ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow, rateLimited: state.rateLimited, lastRateLimitWindow: state.lastRateLimitWindow, }; }; const onStorage = (event) => { if (event.key === LDP_SHARED_REQUEST_STATE_KEY) notifyWaiters(); }; const publishCache = (message) => { try { if (channel) channel.postMessage({ type: 'cache', contextId, message }); } catch (error) {} }; const onChannelMessage = (event) => { const data = event && event.data; if (data && data.type === 'cache' && data.contextId !== contextId) { cacheListeners.forEach((listener) => { try { listener(data.message || {}); } catch (error) {} }); } notifyWaiters(); }; try { channel = new globalThis.BroadcastChannel(LDP_SHARED_REQUEST_CHANNEL); channel.addEventListener('message', onChannelMessage); } catch (error) {} window.addEventListener('storage', onStorage); return { contextId, acquirePermit, recordHostStart, recordHostFinish, finishPermit, acquireCacheFlight, releaseCacheFlight, renewCacheFlight, writeCacheFlight, invalidateCacheWrites, waitForCacheFlight, noteRateLimit, resetRateLimits, noteServerLimits, noteSuccess, snapshot, publishCache, subscribeCache(listener) { if (typeof listener !== 'function') return () => {}; cacheListeners.add(listener); return () => cacheListeners.delete(listener); }, destroy() { if (destroyed) return; destroyed = true; void transact((state) => { state.intents = state.intents.filter((intent) => intent.contextId !== contextId); state.active = state.active.filter((lease) => lease.contextId !== contextId); state.policies = state.policies.filter((policy) => policy.contextId !== contextId); state.flights = state.flights.filter((flight) => flight.contextId !== contextId); }); window.removeEventListener('storage', onStorage); if (channel) channel.close(); channel = null; cacheListeners.clear(); notifyWaiters(); }, }; } function installRequestFlowInstrumentation() { const nativeFetch = window.fetch; if (typeof nativeFetch === 'function' && nativeFetch.__ldpRequestFlowInstrumented !== true) { const trackedFetch = function (input, init) { const event = beginRequestFlow(input, init || {}, 'fetch'); let request; try { request = nativeFetch.apply(this, arguments); } catch (error) { finishRequestFlow(event, { error: error && error.name || 'fetch_error' }); throw error; } return Promise.resolve(request).then((response) => { finishResponseRequestFlow( event, response && response.url, response && response.status, response && response.headers ); captureHostTopicFetchResponse(event, response); return response; }, (error) => { finishRequestFlow(event, { error: error && error.name || 'fetch_error' }); throw error; }); }; Object.defineProperty(trackedFetch, '__ldpRequestFlowInstrumented', { value: true }); window.fetch = trackedFetch; } const xhrPrototype = window.XMLHttpRequest && window.XMLHttpRequest.prototype; if (xhrPrototype && xhrPrototype.open && xhrPrototype.open.__ldpRequestFlowInstrumented !== true) { const nativeOpen = xhrPrototype.open; const nativeSend = xhrPrototype.send; const trackedOpen = function (method, url) { REQUEST_FLOW_XHR_STATE.set(this, { method: String(method || 'GET').toUpperCase(), url }); return nativeOpen.apply(this, arguments); }; const trackedSend = function () { const state = REQUEST_FLOW_XHR_STATE.get(this) || { method: 'GET', url: '' }; const event = beginRequestFlow(state.url, { method: state.method }, 'xmlhttprequest'); const finish = () => { let headers = ''; try { headers = this.getAllResponseHeaders(); } catch (error) {} const finalUrl = finishResponseRequestFlow(event, this.responseURL, this.status, headers); setTimeout(() => captureHostTopicXhrResponse(event, this, finalUrl), 0); }; this.addEventListener('loadend', finish, { once: true }); try { return nativeSend.apply(this, arguments); } catch (error) { finishRequestFlow(event, { error: error && error.name || 'xhr_error' }); throw error; } }; Object.defineProperty(trackedOpen, '__ldpRequestFlowInstrumented', { value: true }); xhrPrototype.open = trackedOpen; xhrPrototype.send = trackedSend; } if (typeof window.PerformanceObserver === 'function') { try { REQUEST_FLOW_RESOURCE_OBSERVER = new window.PerformanceObserver((list, _observer, options) => { list.getEntries().forEach(recordResourceRequestFlow); const dropped = Math.max(0, Number(options && options.droppedEntriesCount) || 0); if (dropped) REQUEST_FLOW_RESOURCE_DROPS.push({ at: requestFlowNow(), count: dropped }); pruneRequestFlowEvents(); }); REQUEST_FLOW_RESOURCE_OBSERVER.observe({ type: 'resource', buffered: true }); } catch (error) {} } } SHARED_REQUEST_COORDINATOR = createSharedRequestCoordinator(); installRequestFlowInstrumentation(); function resetReaderRateAfterChallenge() { if (ACTIVE_READER_REQUEST_SCHEDULER && ACTIVE_READER_REQUEST_SCHEDULER.resetRateLimits) { void ACTIVE_READER_REQUEST_SCHEDULER.resetRateLimits(); return true; } if (!SHARED_REQUEST_COORDINATOR || !SHARED_REQUEST_COORDINATOR.resetRateLimits) return false; void SHARED_REQUEST_COORDINATOR.resetRateLimits(); return true; } const POST_REQUEST_PRIORITY = Object.freeze({ target: -2, topic: -1, scroll: 0, visible: 1, nested: 2, userCard: 1, auxiliary: 3, background: 4, }); const STREAM_PLACEHOLDER_MIN_HEIGHT = 64; const STREAM_ESTIMATED_HEIGHT = 180; const USER_CARD_LINK_SEL = [ '.ldp-user-link[data-user-card]', '.ldp-user-avatar-card[data-user-card]', '.ldp-topic-owner-link[data-user-card]', 'html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.posters,.topic-poster) [data-user-card]', ].join(','); const USER_CARD_PREFETCH_DELAY = 250; const USER_CARD_SHOW_DELAY = 500; const HOST_TOPIC_CACHE_MAX_ENTRIES = 8; const HOST_TOPIC_DATA_CACHE = new Map(); const TOPIC_DATA_CACHE = new Map(); const TOPIC_CACHE_SUBSCRIBERS = new Map(); const TOPIC_SNAPSHOT_WRITE_QUEUE = new Map(); const TOPIC_NAV_ICON_CACHE = new Map(); const USER_CARD_CACHE = new Map(); const USER_CARD_REQUESTS = new Map(); const USER_FOLLOW_LIST_CACHE = new Map(); const USER_FOLLOW_LIST_REQUESTS = new Map(); const NOTIFICATION_CACHE = new Map(); const NOTIFICATION_REQUESTS = new Map(); const NOTIFICATION_AVATAR_CACHE = new Map(); const AVATAR_OBJECT_URL_CACHE = new Map(); const AVATAR_RESOURCE_REQUESTS = new Map(); const AVATAR_HYDRATION_PENDING = new WeakSet(); const EMOJI_IMAGE_OBJECT_URL_CACHE = new Map(); const EMOJI_IMAGE_RESOURCE_REQUESTS = new Map(); const LIGHTBOX_IMAGE_OBJECT_URL_CACHE = new Map(); const LIGHTBOX_IMAGE_RESOURCE_REQUESTS = new Map(); const RESOURCE_CACHE_GROUPS = [ { label: '头像', cacheName: LDP_AVATAR_CACHE_NAME, objectUrls: AVATAR_OBJECT_URL_CACHE, requests: AVATAR_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 32 * 1024 * 1024, }, { label: '表情', cacheName: LDP_EMOJI_IMAGE_CACHE_NAME, objectUrls: EMOJI_IMAGE_OBJECT_URL_CACHE, requests: EMOJI_IMAGE_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 16 * 1024 * 1024, }, { label: '原图', cacheName: LDP_LIGHTBOX_IMAGE_CACHE_NAME, objectUrls: LIGHTBOX_IMAGE_OBJECT_URL_CACHE, requests: LIGHTBOX_IMAGE_RESOURCE_REQUESTS, maxEntries: 24, maxBytes: 96 * 1024 * 1024, }, ]; const RESOURCE_CACHE_WRITES = new Set(); const RESOURCE_CACHE_PRUNE_REQUESTS = new Map(); let RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController(); let RESOURCE_CACHE_CLEARING = false; const RESPONSE_CACHE_MEMORY = new Map(); const RESPONSE_CACHE_REQUESTS = new Map(); const RESPONSE_CACHE_WRITES = new Map(); const RESPONSE_CACHE_VALUE_META = new WeakMap(); const RESPONSE_CACHE_MEMORY_MAX_ENTRIES = 96; const RESPONSE_CACHE_MEMORY_MAX_BYTES = 24 * 1024 * 1024; const RESOURCE_CACHE_PRUNE_BATCH_SIZE = 32; const SEARCH_PINYIN_CACHE_MAX_ENTRIES = 512; let responseCacheDbRequest = null; let responseCachePersistenceRequested = false; let responseCachePruneRequest = null; let responseCachePrunedAt = 0; const SEARCH_PINYIN_CACHE = new Map(); const PERSISTENT_CACHE_WRITE_KEYS = new Set(); let cancelPersistentCacheWrite = null; let persistentCacheWriteDueAt = 0; function trimResponseCacheMemory() { let bytes = 0; RESPONSE_CACHE_MEMORY.forEach((entry) => { bytes += Math.max(0, Number(entry && entry.bytes) || 0); }); while (RESPONSE_CACHE_MEMORY.size > RESPONSE_CACHE_MEMORY_MAX_ENTRIES || (bytes > RESPONSE_CACHE_MEMORY_MAX_BYTES && RESPONSE_CACHE_MEMORY.size > 1)) { const oldestId = RESPONSE_CACHE_MEMORY.keys().next().value; const oldest = RESPONSE_CACHE_MEMORY.get(oldestId); bytes -= Math.max(0, Number(oldest && oldest.bytes) || 0); RESPONSE_CACHE_MEMORY.delete(oldestId); } } function rememberResponseCacheEntry(id, entry) { RESPONSE_CACHE_MEMORY.delete(id); RESPONSE_CACHE_MEMORY.set(id, entry); trimResponseCacheMemory(); } function markResponseCacheStoredAt(value, storedAt) { if (value && typeof value === 'object') { RESPONSE_CACHE_VALUE_META.set(value, Number(storedAt) || Date.now()); } return value; } function responseCacheValueStoredAt(value) { return Number(value && typeof value === 'object' && RESPONSE_CACHE_VALUE_META.get(value)) || 0; } function sharedCacheIdToken(value) { const source = String(value || ''); let hash = 2166136261; for (let index = 0; index < source.length; index++) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); } function openResponseCacheDatabase() { if (responseCacheDbRequest) return responseCacheDbRequest; if (typeof indexedDB === 'undefined') return Promise.resolve(null); let pendingRequest; pendingRequest = new Promise((resolve) => { let settled = false; const finish = (db) => { if (settled) { if (db) db.close(); return; } settled = true; resolve(db); }; let request; try { request = indexedDB.open(LDP_RESPONSE_CACHE_DB_NAME, 1); } catch (error) { finish(null); return; } request.onupgradeneeded = () => { const db = request.result; const store = db.objectStoreNames.contains(LDP_RESPONSE_CACHE_STORE) ? request.transaction.objectStore(LDP_RESPONSE_CACHE_STORE) : db.createObjectStore(LDP_RESPONSE_CACHE_STORE, { keyPath: 'id' }); if (!store.indexNames.contains('kind')) store.createIndex('kind', 'kind', { unique: false }); if (!store.indexNames.contains('tags')) store.createIndex('tags', 'tags', { unique: false, multiEntry: true }); }; request.onsuccess = () => { const db = request.result; db.onversionchange = () => { db.close(); if (responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null; }; finish(db); }; request.onerror = () => finish(null); request.onblocked = () => finish(null); }); responseCacheDbRequest = pendingRequest; pendingRequest.then((db) => { if (!db && responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null; }); return pendingRequest; } function runResponseCacheTransaction(db, mode, operation) { return new Promise((resolve) => { try { const transaction = db.transaction(LDP_RESPONSE_CACHE_STORE, mode); transaction.oncomplete = () => resolve(true); transaction.onerror = transaction.onabort = () => resolve(false); operation(transaction.objectStore(LDP_RESPONSE_CACHE_STORE)); } catch (error) { resolve(false); } }); } function publishResponseCacheMutation(action, details) { if (SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.publishCache({ action, ...details }); } } function requestPersistentReaderStorage() { if (responseCachePersistenceRequested) return; responseCachePersistenceRequested = true; if (!navigator.storage || typeof navigator.storage.persist !== 'function') return; navigator.storage.persist().catch(() => false); } function responseCacheUrl(url) { try { const parsed = new URL(String(url || ''), location.href); ['track_visit', 'forceLoad', '_'].forEach((key) => parsed.searchParams.delete(key)); const postIds = parsed.searchParams.getAll('post_ids[]').sort((a, b) => String(a).localeCompare(String(b))); if (postIds.length) { parsed.searchParams.delete('post_ids[]'); postIds.forEach((id) => parsed.searchParams.append('post_ids[]', id)); } parsed.searchParams.sort(); parsed.hash = ''; return parsed; } catch (error) { return null; } } function responseCacheAuthScope() { const currentUserId = Number(ME_CURRENT_USER && ME_CURRENT_USER.id) || 0; const currentUsername = String(ME_USERNAME || '').trim().toLocaleLowerCase(); const accountIdentity = currentUserId ? `id:${currentUserId}` : currentUsername ? `username:${currentUsername}` : ME_USERNAME === '' ? 'anonymous' : ''; if (accountIdentity) return `account:${sharedCacheIdToken(accountIdentity)}`; const token = String((document.querySelector('meta[name="csrf-token"]') || {}).content || ''); if (!token) return 'session:anonymous'; return `session:${sharedCacheIdToken(token)}`; } function responseCachePolicy(url, fetchOptions = {}, options = {}) { const method = String(fetchOptions.method || 'GET').toUpperCase(); const parsed = responseCacheUrl(url); if (method !== 'GET' || !parsed || parsed.origin !== BASE) return null; const path = parsed.pathname; const tags = new Set(); let kind = 'responses'; let freshFor = HOUR; let retainFor = 30 * DAY; let persist = true; const topicMatch = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/); const byNumberMatch = path.match(/^\/posts\/by_number\/(\d+)\/(\d+)\.json$/); const postMatch = path.match(/^\/posts\/(\d+)(?:\/replies)?\.json$/); const userMatch = path.match(/^\/u\/([^/]+)(?:\/[^/]+)?\.json$/); const bookmarkListMatch = path.match(/^\/u\/[^/]+\/bookmarks\.json$/); const givenReactionsMatch = /^\/discourse-reactions\/posts\/reactions(?:\.json)?$/.test(path); const givenLikesMatch = path === '/user_actions.json' && String(parsed.searchParams.get('filter') || '') .split(',').includes('1'); if (topicMatch) tags.add(`topic:${topicMatch[1]}`); if (byNumberMatch) { tags.add(`topic:${byNumberMatch[1]}`); tags.add(`post-number:${byNumberMatch[1]}:${byNumberMatch[2]}`); } if (postMatch) tags.add(`post:${postMatch[1]}`); parsed.searchParams.getAll('post_ids[]').forEach((id) => tags.add(`post:${id}`)); const queryPostId = Number(parsed.searchParams.get('post_id')); if (queryPostId) tags.add(`post:${queryPostId}`); if (path === '/session/current.json') { kind = 'session'; freshFor = MINUTE; retainFor = 5 * MINUTE; persist = false; } else if (path === '/presence/get') { kind = 'presence'; freshFor = 4 * 1000; retainFor = 20 * 1000; persist = false; const channels = parsed.searchParams.getAll('channels[]').join(' '); const presenceTopic = channels.match(/\/reply\/(\d+)/); if (presenceTopic) tags.add(`topic:${presenceTopic[1]}`); } else if (path === '/notifications.json') { kind = 'notifications'; freshFor = MINUTE; retainFor = 5 * MINUTE; persist = false; tags.add('notifications'); } else if (path === '/emojis.json') { freshFor = 30 * DAY; retainFor = 365 * DAY; tags.add('emoji-registry'); } else if (bookmarkListMatch) { freshFor = MINUTE; tags.add('bookmarks'); } else if (givenReactionsMatch || givenLikesMatch) { freshFor = MINUTE; tags.add('reactions-given'); } else if (path === '/search.json') { freshFor = MINUTE; retainFor = 5 * MINUTE; persist = false; } else if (topicMatch || byNumberMatch || postMatch || path === '/post_voting/comments') { kind = 'topics'; freshFor = path.includes('/replies.json') || path === '/post_voting/comments' ? 5 * MINUTE : 30 * MINUTE; retainFor = 180 * DAY; } else if (path.startsWith('/category-experts/endorsable-categories/')) { kind = 'users'; freshFor = MINUTE; retainFor = 5 * MINUTE; persist = false; const endorsedUser = path.match(/^\/category-experts\/endorsable-categories\/([^/.]+)\.json$/); if (endorsedUser) tags.add(`user:${decodeURIComponent(endorsedUser[1]).toLocaleLowerCase()}`); } else if (userMatch || path.startsWith('/directory_items.json')) { kind = 'users'; freshFor = 24 * HOUR; retainFor = 180 * DAY; if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`); const directoryUsername = parsed.searchParams.get('username'); if (directoryUsername) tags.add(`user:${directoryUsername.toLocaleLowerCase()}`); } const scope = responseCacheAuthScope(); return { id: `${scope}|${parsed.href}`, kind, tags: Array.from(tags), freshFor, retainFor, persist, cacheMode: options.cacheMode || 'default', }; } async function getStoredResponseCacheEntry(id) { if (RESPONSE_CACHE_MEMORY.has(id)) { const entry = RESPONSE_CACHE_MEMORY.get(id); rememberResponseCacheEntry(id, entry); return entry; } const db = await openResponseCacheDatabase(); if (!db) return null; let entry = null; await runResponseCacheTransaction(db, 'readonly', (store) => { const request = store.get(id); request.onsuccess = () => { entry = request.result || null; }; }); if (entry) rememberResponseCacheEntry(id, entry); return entry; } async function deleteStoredResponseCacheEntry(id) { RESPONSE_CACHE_MEMORY.delete(id); await queueStoredResponseCacheWrite(id, (store) => store.delete(id)); publishResponseCacheMutation('response-delete', { token: sharedCacheIdToken(id) }); } function storedResponseCacheEntry(policy, value, bytes = policy.bytes) { const storedAt = Date.now(); return { id: policy.id, kind: policy.kind, tags: policy.tags, storedAt, expiresAt: storedAt + policy.retainFor, bytes: Number.isFinite(bytes) ? Math.max(0, Number(bytes)) : estimateCacheBytes(value), value, }; } function queueStoredResponseCacheWrite(id, updateStore, afterWrite) { const previousWrite = RESPONSE_CACHE_WRITES.get(id) || Promise.resolve(); return trackSingleFlightRequest(RESPONSE_CACHE_WRITES, id, previousWrite.catch(() => {}).then(() => openResponseCacheDatabase()) .then((db) => db && runResponseCacheTransaction(db, 'readwrite', updateStore)) .then((stored) => afterWrite ? afterWrite(stored) : stored).catch(() => null)); } function setStoredResponseCacheEntry(policy, value) { const entry = storedResponseCacheEntry(policy, value); rememberResponseCacheEntry(policy.id, entry); if (!policy.persist) return Promise.resolve(); requestPersistentReaderStorage(); return queueStoredResponseCacheWrite(policy.id, (store) => store.put(entry), (stored) => { if (stored) publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) }); }); } function topicSnapshotPolicy(topicId, bytes) { const safeTopicId = Number(topicId); if (!(safeTopicId > 0)) return null; return { id: `${responseCacheAuthScope()}|snapshot:topic:${safeTopicId}`, kind: 'topics', tags: [`topic:${safeTopicId}`], freshFor: TOPIC_CACHE_TTL, retainFor: PERSISTENT_CACHE_CONFIG.topics.maxAge, persist: true, bytes, }; } function estimateTopicSnapshotBytes(topic) { const postStream = topic && topic.post_stream || {}; const posts = Array.isArray(postStream.posts) ? postStream.posts : []; const stream = Array.isArray(postStream.stream) ? postStream.stream : []; let bytes = 4096 + stream.length * 16; posts.forEach((post) => { if (!post || typeof post !== 'object') return; bytes += 1024; ['cooked', 'raw', 'name', 'username', 'display_username', 'avatar_template'].forEach((key) => { bytes += String(post[key] || '').length * 2; }); }); return bytes; } function subscribeTopicCache(topicId, listener) { const key = String(Number(topicId) || ''); if (!key || typeof listener !== 'function') return () => {}; if (!TOPIC_CACHE_SUBSCRIBERS.has(key)) TOPIC_CACHE_SUBSCRIBERS.set(key, new Set()); const listeners = TOPIC_CACHE_SUBSCRIBERS.get(key); listeners.add(listener); return () => { listeners.delete(listener); if (!listeners.size) TOPIC_CACHE_SUBSCRIBERS.delete(key); }; } function notifyTopicCacheSubscribers(topicId, topic) { const listeners = TOPIC_CACHE_SUBSCRIBERS.get(String(Number(topicId) || '')); if (!listeners || !listeners.size || !topic) return; listeners.forEach((listener) => { try { listener(topic); } catch (error) {} }); } function mergeStoredTopicSnapshot(policy, payload) { let finalEntry = null; let finalPayload = null; let mergedPosts = false; return queueStoredResponseCacheWrite(policy.id, (store) => { const request = store.get(policy.id); request.onsuccess = () => { const existing = request.result; const existingValue = existing && existing.value; const existingValid = existingValue && existingValue.version === TOPIC_SNAPSHOT_VERSION && Number(existingValue.topicId) === Number(payload.topicId) && existingValue.topic; if (existingValid && Number(existingValue.updatedAt) > Number(payload.updatedAt)) { mergedPosts = mergeMissingSnapshotPosts(existingValue.topic, payload.topic); finalPayload = { ...existingValue, lastAccessAt: Math.max(Number(existingValue.lastAccessAt) || 0, Number(payload.lastAccessAt) || 0), topic: existingValue.topic, }; } else { if (existingValid) mergedPosts = mergeMissingSnapshotPosts(payload.topic, existingValue.topic); finalPayload = payload; } finalEntry = storedResponseCacheEntry( policy, finalPayload, estimateTopicSnapshotBytes(finalPayload.topic) ); try { store.put(finalEntry); } catch (error) { finalEntry = null; finalPayload = null; } }; request.onerror = () => { finalEntry = null; finalPayload = null; }; }, (stored) => { if (stored && finalEntry) { rememberResponseCacheEntry(policy.id, finalEntry); publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) }); } return stored && finalPayload ? { payload: finalPayload, mergedPosts } : null; }); } async function flushTopicSnapshotWrite(topicId) { const key = String(Number(topicId) || ''); const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key); if (queued && queued.timer) clearTimeout(queued.timer); TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key); const entry = TOPIC_DATA_CACHE.get(key); const scope = responseCacheAuthScope(); if (!entry || entry.scope !== scope || !entry.topic) return; const policy = topicSnapshotPolicy(key, estimateTopicSnapshotBytes(entry.topic)); if (!policy) return; const storedResult = await mergeStoredTopicSnapshot(policy, { version: TOPIC_SNAPSHOT_VERSION, topicId: Number(key), fetchedAt: Number(entry.fetchedAt) || Date.now(), lastAccessAt: Number(entry.lastAccessAt) || Date.now(), updatedAt: Number(entry.updatedAt) || Date.now(), topic: entry.topic, }); if (!storedResult) return; const stored = storedResult.payload; if (stored.topic !== entry.topic || Number(stored.updatedAt) !== Number(entry.updatedAt)) { rememberCachedTopicData(key, stored.topic, { fetchedAt: stored.fetchedAt, lastAccessAt: stored.lastAccessAt, updatedAt: stored.updatedAt, full: true, notify: true, source: 'snapshot-merge', }); } else if (storedResult.mergedPosts) { notifyTopicCacheSubscribers(key, stored.topic); } if (SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.publishCache({ action: 'topic-snapshot-write', topicId: Number(key), scopeToken: sharedCacheIdToken(scope), updatedAt: Number(stored.updatedAt) || Date.now(), }); } } function scheduleTopicSnapshotWrite(topicId) { const key = String(Number(topicId) || ''); const entry = TOPIC_DATA_CACHE.get(key); if (!key || !entry || entry.scope !== responseCacheAuthScope()) return; const now = Date.now(); const previous = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key); if (previous && previous.timer) clearTimeout(previous.timer); const firstQueuedAt = Number(previous && previous.firstQueuedAt) || now; const dueAt = Math.min(now + TOPIC_SNAPSHOT_WRITE_DELAY, firstQueuedAt + TOPIC_SNAPSHOT_WRITE_MAX_DELAY); const timer = setTimeout(() => { void flushTopicSnapshotWrite(key); }, Math.max(0, dueAt - now)); TOPIC_SNAPSHOT_WRITE_QUEUE.set(key, { firstQueuedAt, timer }); } function cancelTopicSnapshotWrites(topicId) { const keys = topicId == null ? Array.from(TOPIC_SNAPSHOT_WRITE_QUEUE.keys()) : [String(Number(topicId) || '')]; keys.forEach((key) => { const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key); if (queued && queued.timer) clearTimeout(queued.timer); TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key); }); } function flushTopicSnapshotWrites() { return Promise.all(Array.from(TOPIC_SNAPSHOT_WRITE_QUEUE.keys(), (topicId) => ( flushTopicSnapshotWrite(topicId) ))); } function mergeMissingSnapshotPosts(targetTopic, sourceTopic) { const targetStream = targetTopic && targetTopic.post_stream; const sourceStream = sourceTopic && sourceTopic.post_stream; if (!targetStream || !sourceStream) return false; const current = Array.isArray(targetStream.posts) ? targetStream.posts : []; const indexes = new Map(current.map((post, index) => [Number(post && post.id), index])); let changed = false; (Array.isArray(sourceStream.posts) ? sourceStream.posts : []).forEach((post) => { const postId = Number(post && post.id); if (!postId || indexes.has(postId)) return; indexes.set(postId, current.length); current.push(post); changed = true; }); targetStream.posts = current; if ((!Array.isArray(targetStream.stream) || !targetStream.stream.length) && Array.isArray(sourceStream.stream)) { targetStream.stream = sourceStream.stream.slice(); changed = true; } return changed; } async function getStoredTopicSnapshot(topicId, options = {}) { const policy = topicSnapshotPolicy(topicId); if (!policy) return null; if (options.preferStored === true) RESPONSE_CACHE_MEMORY.delete(policy.id); const stored = await getStoredResponseCacheEntry(policy.id); const value = stored && stored.value; const now = Date.now(); const fetchedAt = Number(value && value.fetchedAt) || Number(stored && stored.storedAt) || 0; if (!value || value.version !== TOPIC_SNAPSHOT_VERSION || Number(value.topicId) !== Number(topicId) || !value.topic || !(fetchedAt > 0) || now - fetchedAt > PERSISTENT_CACHE_CONFIG.topics.maxAge) { if (stored) await deleteStoredResponseCacheEntry(policy.id); return null; } const key = String(Number(topicId)); const current = TOPIC_DATA_CACHE.get(key); const storedUpdatedAt = Number(value.updatedAt) || Number(stored.storedAt) || fetchedAt; if (current && current.scope === responseCacheAuthScope() && current.topic) { const mergedIntoCurrent = mergeMissingSnapshotPosts(current.topic, value.topic); const currentUpdatedAt = Number(current.updatedAt) || 0; const currentWins = currentUpdatedAt > storedUpdatedAt || (currentUpdatedAt === storedUpdatedAt && options.preferStored !== true); if (currentWins) { if (mergedIntoCurrent) notifyTopicCacheSubscribers(topicId, current.topic); return current.topic; } mergeMissingSnapshotPosts(value.topic, current.topic); } rememberCachedTopicData(topicId, value.topic, { fetchedAt, lastAccessAt: now, updatedAt: storedUpdatedAt, full: true, notify: true, source: options.preferStored === true ? 'shared-snapshot' : 'snapshot', }); return value.topic; } function givenReactionSnapshotPolicy(username) { const normalizedUsername = String(username || '').trim().toLocaleLowerCase(); if (!normalizedUsername) return null; return { id: `${responseCacheAuthScope()}|snapshot:reactions-given:${encodeURIComponent(normalizedUsername)}`, kind: 'responses', tags: ['reactions-given'], freshFor: MINUTE, retainFor: 30 * DAY, persist: true, }; } async function getGivenReactionSnapshot(username) { const policy = givenReactionSnapshotPolicy(username); if (!policy) return null; const cached = await getStoredResponseCacheEntry(policy.id); if (!cached) return null; const storedAt = Number(cached.storedAt || 0); const age = Date.now() - storedAt; const value = cached.value || {}; if (!(storedAt > 0) || age > policy.retainFor || value.version !== 1 || !Array.isArray(value.entries)) { await deleteStoredResponseCacheEntry(policy.id); return null; } return { entries: value.entries, storedAt, fresh: age <= policy.freshFor, }; } function setGivenReactionSnapshot(username, entries) { const policy = givenReactionSnapshotPolicy(username); if (!policy) return; setStoredResponseCacheEntry(policy, { version: 1, entries: Array.isArray(entries) ? entries : [], }); } function responseCacheCanFallback(error) { if (!error || error.name === 'AbortError') return false; const status = Number(error.status || 0); return !status || status === 408 || status === 429 || status >= 500; } async function requestCachedJSON(url, fetchOptions, options, networkRequest) { const policy = responseCachePolicy(url, fetchOptions, options); if (!policy || policy.cacheMode === 'bypass') return networkRequest(); let cached = await getStoredResponseCacheEntry(policy.id); const cachedBeforeRequest = Number(cached && cached.storedAt || 0); let age = cached ? Date.now() - Number(cached.storedAt || 0) : Infinity; if (cached && age > policy.retainFor) { await deleteStoredResponseCacheEntry(policy.id); cached = null; age = Infinity; } else if (cached && policy.cacheMode !== 'refresh' && age <= policy.freshFor) { return markResponseCacheStoredAt(cached.value, cached.storedAt); } const requestKey = `${policy.cacheMode}:${policy.id}`; if (RESPONSE_CACHE_REQUESTS.has(requestKey)) return RESPONSE_CACHE_REQUESTS.get(requestKey); const request = (async () => { const flightToken = policy.persist && SHARED_REQUEST_COORDINATOR ? sharedCacheIdToken(policy.id) : ''; const readLatestSharedCache = async () => { RESPONSE_CACHE_MEMORY.delete(policy.id); const latest = await getStoredResponseCacheEntry(policy.id); const storedAt = Number(latest && latest.storedAt || 0); const latestAge = latest ? Date.now() - storedAt : Infinity; if (!latest || latestAge > policy.retainFor) return undefined; cached = latest; age = latestAge; const usable = policy.cacheMode === 'refresh' ? storedAt > cachedBeforeRequest : latestAge <= policy.freshFor; return usable ? markResponseCacheStoredAt(latest.value, storedAt) : undefined; }; let ownedFlight = null; let flightHeartbeat = 0; try { if (flightToken) { const flightDeadline = Date.now() + SHARED_CACHE_FLIGHT_TTL * 2 + 5000; while (!ownedFlight) { if (Date.now() >= flightDeadline) { throw requestTimeoutError('等待共享缓存请求超时'); } const flight = await SHARED_REQUEST_COORDINATOR.acquireCacheFlight(flightToken); if (!flight.producer) { const released = await SHARED_REQUEST_COORDINATOR.waitForCacheFlight( flightToken, fetchOptions.signal, flightDeadline ); if (!released) { throw requestTimeoutError('等待共享缓存请求超时'); } const shared = await readLatestSharedCache(); if (shared !== undefined) return shared; continue; } ownedFlight = flight; flightHeartbeat = setInterval(() => { SHARED_REQUEST_COORDINATOR.renewCacheFlight(flightToken, flight.flightId).then((renewed) => { if (renewed || !flightHeartbeat) return; clearInterval(flightHeartbeat); flightHeartbeat = 0; }).catch(() => {}); }, 15000); const lateCached = await readLatestSharedCache(); if (lateCached !== undefined) return lateCached; } } const value = markResponseCacheStoredAt(await networkRequest(), Date.now()); if (ownedFlight && SHARED_REQUEST_COORDINATOR) { await SHARED_REQUEST_COORDINATOR.writeCacheFlight( flightToken, ownedFlight.flightId, ownedFlight.cacheEpoch, () => setStoredResponseCacheEntry(policy, value) ); } else { await setStoredResponseCacheEntry(policy, value); } return value; } catch (error) { if (options.allowStaleOnError !== false && cached && age <= policy.retainFor && responseCacheCanFallback(error)) { return markResponseCacheStoredAt(cached.value, cached.storedAt); } throw error; } finally { if (flightHeartbeat) clearInterval(flightHeartbeat); flightHeartbeat = 0; if (ownedFlight && SHARED_REQUEST_COORDINATOR) { await SHARED_REQUEST_COORDINATOR.releaseCacheFlight(flightToken, ownedFlight.flightId); } } })(); return trackSingleFlightRequest(RESPONSE_CACHE_REQUESTS, requestKey, request); } async function scanStoredResponseCache(mode, visit) { const db = await openResponseCacheDatabase(); if (!db) return false; await runResponseCacheTransaction(db, mode, (store) => { const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return; visit(cursor.value || {}, cursor); cursor.continue(); }; }); return true; } function storedResponseCachePruneIds(entries, entryExpiresAt, now) { const removeIds = new Set(); const entriesByKind = new Map(); entries.forEach((entry) => { const id = String(entry && entry.id || ''); if (!id) return; if (entryExpiresAt(entry) <= now) { removeIds.add(id); return; } const kind = PERSISTENT_CACHE_CONFIG[entry.kind] ? entry.kind : 'responses'; if (!entriesByKind.has(kind)) entriesByKind.set(kind, []); entriesByKind.get(kind).push(entry); }); entriesByKind.forEach((kindEntries, kind) => { const config = PERSISTENT_CACHE_CONFIG[kind] || PERSISTENT_CACHE_CONFIG.responses; const maxEntries = Math.max(0, Number(config.storedMaxEntries) || 0); const maxBytes = Math.max(0, Number(config.storedMaxBytes) || 0); if (!maxEntries && !maxBytes) return; let keptEntries = 0; let keptBytes = 0; kindEntries.sort((left, right) => Number(right && right.storedAt || 0) - Number(left && left.storedAt || 0)); kindEntries.forEach((entry) => { const bytes = Math.max(0, Number(entry && entry.bytes) || 0); const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries; const exceedsBytes = maxBytes > 0 && keptBytes + bytes > maxBytes; if (exceedsEntries || exceedsBytes) { removeIds.add(String(entry.id)); return; } keptEntries++; keptBytes += bytes; }); }); return removeIds; } async function pruneStoredResponseCache(force = false) { const now = Date.now(); if (!force && now - responseCachePrunedAt < HOUR) return; if (responseCachePruneRequest) return responseCachePruneRequest; const entryExpiresAt = (entry) => { const explicit = Number(entry && entry.expiresAt); if (explicit > 0) return explicit; const id = String(entry && entry.id || ''); const separator = id.indexOf('|'); const policy = separator >= 0 ? responseCachePolicy(id.slice(separator + 1)) : null; return Number(entry && entry.storedAt || 0) + (policy ? policy.retainFor : PERSISTENT_CACHE_CONFIG.responses.maxAge); }; responseCachePruneRequest = (async () => { const db = await openResponseCacheDatabase(); if (!db) return; let scanned = false; const pruned = await runResponseCacheTransaction(db, 'readwrite', (store) => { const entries = []; const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (cursor) { const entry = cursor.value || {}; entries.push({ id: entry.id, kind: entry.kind, storedAt: entry.storedAt, expiresAt: entry.expiresAt, bytes: entry.bytes, }); cursor.continue(); return; } storedResponseCachePruneIds(entries, entryExpiresAt, now).forEach((id) => { RESPONSE_CACHE_MEMORY.delete(id); store.delete(id); }); scanned = true; }; }); if (!pruned || !scanned) return; responseCachePrunedAt = now; })().finally(() => { responseCachePruneRequest = null; }); return responseCachePruneRequest; } async function clearStoredResponseCache({ kinds = [], tags = [], all = false } = {}) { if (SHARED_REQUEST_COORDINATOR) { await SHARED_REQUEST_COORDINATOR.invalidateCacheWrites(); } if (RESPONSE_CACHE_WRITES.size) await Promise.all(Array.from(RESPONSE_CACHE_WRITES.values())); const kindSet = new Set(kinds); const tagSet = new Set(tags); RESPONSE_CACHE_MEMORY.forEach((entry, id) => { if (all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag))) { RESPONSE_CACHE_MEMORY.delete(id); } }); const db = await openResponseCacheDatabase(); if (db) { await runResponseCacheTransaction(db, 'readwrite', (store) => { if (all) { store.clear(); } else { const lookups = [ ...Array.from(kindSet, (kind) => [store.index('kind'), kind]), ...Array.from(tagSet, (tag) => [store.index('tags'), tag]), ]; const ids = new Set(); let pending = lookups.length; lookups.forEach(([index, value]) => { const request = index.getAllKeys(IDBKeyRange.only(value)); request.onsuccess = () => { (request.result || []).forEach((id) => ids.add(id)); pending--; if (!pending) ids.forEach((id) => store.delete(id)); }; request.onerror = () => { pending--; if (!pending) ids.forEach((id) => store.delete(id)); }; }); } }); } publishResponseCacheMutation('response-clear', { kinds, tags, all }); } async function storedResponseCacheStats() { await pruneStoredResponseCache(true); const counts = { topics: { count: 0, bytes: 0 }, users: { count: 0, bytes: 0 }, notifications: { count: 0, bytes: 0 }, responses: { count: 0, bytes: 0 }, }; let topicSnapshotCount = 0; let topicRequestCount = 0; const topicIds = new Set(); const postIds = new Set(); const userNames = new Set(); const notificationIds = new Set(); const responseRecords = { bookmarks: 0, reactions: 0, emojiRegistry: 0, other: 0 }; const result = () => ({ topics: { ...counts.topics, topicIds: Array.from(topicIds), postCount: postIds.size, topicSnapshotCount, topicRequestCount }, users: { ...counts.users, userNames: Array.from(userNames) }, notifications: { ...counts.notifications, notificationIds: Array.from(notificationIds) }, responses: { ...counts.responses, responseRecords }, }); const scanned = await scanStoredResponseCache('readonly', (entry) => { const stat = counts[entry.kind]; if (stat) { stat.count++; stat.bytes += Math.max(0, Number(entry.bytes) || 0); const tags = new Set(Array.isArray(entry.tags) ? entry.tags.map(String) : []); if (entry.kind === 'topics') { tags.forEach((tag) => { const match = String(tag).match(/^topic:(\d+)$/); if (match) topicIds.add(Number(match[1])); }); const value = entry.value; const isTopicSnapshot = String(entry.id || '').includes('|snapshot:topic:') || !!(value && value.version === TOPIC_SNAPSHOT_VERSION && value.topicId && value.topic); if (isTopicSnapshot) topicSnapshotCount++; else topicRequestCount++; const snapshotTopic = isTopicSnapshot && value ? value.topic : value; const snapshotTopicId = Number(value && value.topicId || snapshotTopic && snapshotTopic.id || 0); if (snapshotTopicId > 0) topicIds.add(snapshotTopicId); postsFromPayload(snapshotTopic).forEach((post) => { const topicId = Number(post && post.topic_id || snapshotTopicId || 0); if (topicId > 0) topicIds.add(topicId); const postId = Number(post && post.id || 0); const postNumber = Number(post && post.post_number || 0); if (postId > 0) postIds.add(`id:${postId}`); else if (topicId > 0 && postNumber > 0) postIds.add(`number:${topicId}:${postNumber}`); }); } else if (entry.kind === 'users') { tags.forEach((tag) => { const match = tag.match(/^user:(.+)$/); if (match && match[1]) userNames.add(match[1].toLocaleLowerCase()); }); const value = entry.value || {}; const candidates = [value.user, value]; (Array.isArray(value.directory_items) ? value.directory_items : []).forEach((item) => { candidates.push(item && (item.user || item)); }); candidates.forEach((user) => { const username = cleanUsernameValue(user && user.username).toLocaleLowerCase(); if (username) userNames.add(username); }); } else if (entry.kind === 'notifications') { const notifications = entry.value && Array.isArray(entry.value.notifications) ? entry.value.notifications : []; addNotificationStatsIdentities(notificationIds, notifications); } else if (entry.kind === 'responses') { if (tags.has('bookmarks')) responseRecords.bookmarks++; else if (tags.has('reactions-given')) responseRecords.reactions++; else if (tags.has('emoji-registry')) responseRecords.emojiRegistry++; else responseRecords.other++; } } }); if (!scanned) return result(); return result(); } function responseCacheMutationTags(url, params, data) { const parsed = responseCacheUrl(url); if (parsed && parsed.pathname === '/topics/timings') return []; const tags = new Set(); const source = Object.assign({}, params || {}, data || {}); const topicId = Number(source.topic_id || source.topicId || (source.bookmarkable_type === 'Topic' && source.bookmarkable_id) || 0); const postId = Number(source.post_id || source.postId || (source.bookmarkable_type === 'Post' && source.bookmarkable_id) || (data && data.post && data.post.id) || 0); if (topicId) tags.add(`topic:${topicId}`); if (postId) tags.add(`post:${postId}`); if (parsed) { const topicMatch = parsed.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)/); const postMatch = parsed.pathname.match(/^\/(?:posts|post_actions)\/(\d+)/); const reactionPostMatch = parsed.pathname.match(/^\/discourse-reactions\/posts\/(\d+)/); const userMatch = parsed.pathname.match(/^\/(?:u|follow|category-experts\/endorse)\/([^/.]+)/); if (topicMatch) tags.add(`topic:${topicMatch[1]}`); if (postMatch) tags.add(`post:${postMatch[1]}`); if (reactionPostMatch) { tags.add(`post:${reactionPostMatch[1]}`); tags.add('reactions-given'); } if (parsed.pathname === '/post_actions' && Number(params && params.id)) { tags.add(`post:${Number(params.id)}`); } if (parsed.pathname === '/post_actions' || /^\/post_actions\/\d+/.test(parsed.pathname)) { tags.add('reactions-given'); } if (parsed.pathname === '/bookmarks' || parsed.pathname.startsWith('/bookmarks/') || /\/remove_bookmarks$/.test(parsed.pathname)) tags.add('bookmarks'); if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`); if (parsed.pathname.startsWith('/notifications/')) tags.add('notifications'); } return Array.from(tags); } function scheduleIdleWork(callback, timeout = 1200) { let cancelled = false; const run = () => { if (!cancelled) callback(); }; if (typeof requestIdleCallback === 'function') { const id = requestIdleCallback(run, { timeout }); return () => { cancelled = true; if (typeof cancelIdleCallback === 'function') cancelIdleCallback(id); }; } const id = setTimeout(run, timeout); return () => { cancelled = true; clearTimeout(id); }; } function trimPersistentCacheMap(key) { const cache = cacheMapForKey(key); const maxEntries = Number(PERSISTENT_CACHE_CONFIG[key] && PERSISTENT_CACHE_CONFIG[key].maxEntries) || 0; if (!cache || !maxEntries) return; while (cache.size > maxEntries) cache.delete(cache.keys().next().value); } function topicCacheEntryTimestamp(entry) { return Number(entry && entry.fetchedAt) || Number(entry && entry.at) || 0; } function rememberCachedTopicData(topicId, topic, options = {}) { const key = String(Number(topicId) || ''); if (!key || !topic) return null; const now = Date.now(); const scope = String(options.scope || responseCacheAuthScope()); const previous = TOPIC_DATA_CACHE.get(key); const sameScope = previous && previous.scope === scope ? previous : null; const entry = { topic, scope, fetchedAt: Number(options.fetchedAt) || topicCacheEntryTimestamp(sameScope) || now, lastAccessAt: Number(options.lastAccessAt) || now, updatedAt: Number(options.updatedAt) || now, source: String(options.source || sameScope && sameScope.source || 'memory'), }; TOPIC_DATA_CACHE.delete(key); TOPIC_DATA_CACHE.set(key, entry); trimPersistentCacheMap('topics'); if (options.notify !== false) notifyTopicCacheSubscribers(key, topic); return entry; } function getCachedTopicData(topicId) { const key = String(Number(topicId) || ''); if (!key) return null; const now = Date.now(); const scope = responseCacheAuthScope(); let entry = TOPIC_DATA_CACHE.get(key); if (entry && entry.scope !== scope) { if (ME_USERNAME === null && String(entry.scope || '').startsWith('account:')) return null; const canRescopeHost = entry.source === 'host' && now - topicCacheEntryTimestamp(entry) <= TOPIC_CACHE_TTL; if (canRescopeHost) { entry.scope = scope; entry.updatedAt = now; scheduleTopicSnapshotWrite(key); } else { TOPIC_DATA_CACHE.delete(key); entry = null; } } if (entry && now - topicCacheEntryTimestamp(entry) > PERSISTENT_CACHE_CONFIG.topics.maxAge) { TOPIC_DATA_CACHE.delete(key); entry = null; } if (!entry) { const hostEntry = HOST_TOPIC_DATA_CACHE.get(key); HOST_TOPIC_DATA_CACHE.delete(key); if (hostEntry && now - Number(hostEntry.at || 0) <= TOPIC_CACHE_TTL) { entry = rememberCachedTopicData(key, hostEntry.topic, { fetchedAt: Number(hostEntry.at) || now, source: 'host', }); scheduleTopicSnapshotWrite(key); } } if (!entry) return null; TOPIC_DATA_CACHE.delete(key); entry.lastAccessAt = now; TOPIC_DATA_CACHE.set(key, entry); return entry.topic; } function setCachedTopicData(topicId, topic, options = {}) { const entry = rememberCachedTopicData(topicId, topic, options); if (entry) scheduleTopicSnapshotWrite(topicId); } function cleanUsernameValue(value) { return String(value || '').replace(/^@/, '').trim(); } function authScopedCacheKey(key) { return `${responseCacheAuthScope()}|${key}`; } function currentAuthCacheKey(key) { return ME_USERNAME !== null && String(key).startsWith(authScopedCacheKey('')); } function getCachedUserCard(username) { const key = authScopedCacheKey(cleanUsernameValue(username)); const entry = USER_CARD_CACHE.get(key); if (!entry) return null; if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.users.maxAge) { USER_CARD_CACHE.delete(key); schedulePersistentCacheWrite('users'); return null; } USER_CARD_CACHE.delete(key); entry.at = Date.now(); USER_CARD_CACHE.set(key, entry); return entry.user || null; } function setCachedUserCard(username, user, persistDelay = 250) { const cleanUsername = cleanUsernameValue(username); if (!cleanUsername || !user) return; const key = authScopedCacheKey(cleanUsername); USER_CARD_CACHE.delete(key); USER_CARD_CACHE.set(key, { user, at: Date.now() }); trimPersistentCacheMap('users'); schedulePersistentCacheWrite('users', persistDelay); } function cacheMapForKey(key) { if (key === 'topics') return TOPIC_DATA_CACHE; if (key === 'topicNavIcons') return TOPIC_NAV_ICON_CACHE; if (key === 'users') return USER_CARD_CACHE; if (key === 'notifications') return NOTIFICATION_CACHE; return null; } function readPersistentCacheEntries(key) { const config = PERSISTENT_CACHE_CONFIG[key]; if (!config || !config.storageKey || key === 'history') return []; try { const data = JSON.parse(localStorage.getItem(config.storageKey) || 'null'); if (!data || data.version !== PERSISTENT_CACHE_VERSION || !Array.isArray(data.entries)) return []; return data.entries; } catch (e) { return []; } } function boundedPersistentEntries(key, entries) { const config = PERSISTENT_CACHE_CONFIG[key]; if (!config) return []; const now = Date.now(); const bounded = (Array.isArray(entries) ? entries : []).filter((entry) => { if (!Array.isArray(entry) || entry.length !== 2 || entry[0] == null || !entry[1]) return false; if ((key === 'users' || key === 'notifications') && ME_USERNAME !== null && !currentAuthCacheKey(entry[0])) return false; return Number.isFinite(Number(entry[1].at)) && now - Number(entry[1].at) <= config.maxAge; }); return config.maxEntries ? bounded.slice(-config.maxEntries) : bounded; } function isStorageQuotaError(error) { return !!error && (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' || error.code === 22 || error.code === 1014); } function persistCacheType(key) { const config = PERSISTENT_CACHE_CONFIG[key]; const cache = cacheMapForKey(key); if (!config || !cache) return; const merged = new Map(readPersistentCacheEntries(key).map(([entryKey, value]) => [String(entryKey), value])); cache.forEach((value, entryKey) => { const normalizedKey = String(entryKey); const previous = merged.get(normalizedKey); if (!previous || Number(value && value.at || 0) >= Number(previous && previous.at || 0)) { merged.delete(normalizedKey); merged.set(normalizedKey, value); } }); const entries = boundedPersistentEntries(key, Array.from(merged.entries())); while (true) { try { if (entries.length) localStorage.setItem(config.storageKey, JSON.stringify({ version: PERSISTENT_CACHE_VERSION, entries })); else localStorage.removeItem(config.storageKey); break; } catch (error) { if (!isStorageQuotaError(error) || !entries.length) return; entries.shift(); } } cache.clear(); entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value)); if (SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-update', key }); } } function flushPersistentCacheWrites() { if (cancelPersistentCacheWrite) { const cancel = cancelPersistentCacheWrite; cancelPersistentCacheWrite = null; cancel(); } persistentCacheWriteDueAt = 0; const keys = Array.from(PERSISTENT_CACHE_WRITE_KEYS); PERSISTENT_CACHE_WRITE_KEYS.clear(); keys.forEach(persistCacheType); } function schedulePersistentCacheWrite(key, delay = 2000) { if (!cacheMapForKey(key) || !PERSISTENT_CACHE_CONFIG[key]?.storageKey) return; PERSISTENT_CACHE_WRITE_KEYS.add(key); const safeDelay = Math.max(0, Number(delay) || 0); const dueAt = Date.now() + safeDelay; if (cancelPersistentCacheWrite && persistentCacheWriteDueAt <= dueAt) return; if (cancelPersistentCacheWrite) cancelPersistentCacheWrite(); persistentCacheWriteDueAt = dueAt; cancelPersistentCacheWrite = scheduleIdleWork(() => { cancelPersistentCacheWrite = null; persistentCacheWriteDueAt = 0; flushPersistentCacheWrites(); }, safeDelay); } function restorePersistentCaches() { try { localStorage.removeItem(LDP_TOPIC_CACHE_KEY); } catch (error) {} ['topicNavIcons', 'users', 'notifications'].forEach((key) => { const cache = cacheMapForKey(key); if (!cache) return; const rawEntries = readPersistentCacheEntries(key); const entries = boundedPersistentEntries(key, rawEntries); entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value)); if (key === 'notifications') { entries.forEach(([, entry]) => { const notifications = entry && entry.value && entry.value.notifications; if (!Array.isArray(notifications)) return; notifications.forEach((notification) => { const data = notificationData(notification); const template = notification.acting_user_avatar_template || notification.avatar_template || data.acting_user_avatar_template || data.avatar_template || ''; const actor = notificationActor(notification); if (actor && template) rememberNotificationAvatar(actor, template); }); }); } if (entries.length !== rawEntries.length) schedulePersistentCacheWrite(key); }); } function mergeSharedPersistentCache(key) { const cache = cacheMapForKey(key); if (!cache) return; let changed = false; boundedPersistentEntries(key, readPersistentCacheEntries(key)).forEach(([entryKey, value]) => { const normalizedKey = String(entryKey); const current = cache.get(normalizedKey); if (!current || Number(value && value.at || 0) > Number(current && current.at || 0)) { cache.delete(normalizedKey); cache.set(normalizedKey, value); changed = true; } }); trimPersistentCacheMap(key); if (changed) schedulePersistentCacheWrite(key, 0); } function clearPersistentCacheMaps(keys) { (Array.isArray(keys) ? keys : []).forEach((key) => { if (key === 'topics') { cancelTopicSnapshotWrites(); HOST_TOPIC_DATA_CACHE.clear(); } const cache = cacheMapForKey(String(key)); if (cache) cache.clear(); if (key === 'users') { USER_CARD_REQUESTS.clear(); USER_FOLLOW_LIST_CACHE.clear(); USER_FOLLOW_LIST_REQUESTS.clear(); } if (key === 'notifications') NOTIFICATION_REQUESTS.clear(); }); } function applySharedCacheMessage(message) { const action = String(message && message.action || ''); if (action === 'topic-snapshot-write') { const topicId = Number(message.topicId); if (!(topicId > 0) || String(message.scopeToken || '') !== sharedCacheIdToken(responseCacheAuthScope())) return; const policy = topicSnapshotPolicy(topicId); if (policy) RESPONSE_CACHE_MEMORY.delete(policy.id); void getStoredTopicSnapshot(topicId, { preferStored: true }); return; } if (action === 'map-update') { mergeSharedPersistentCache(String(message.key || '')); return; } if (action === 'map-delete' && message.key === 'topics') { const key = String(message.entryKey || ''); TOPIC_DATA_CACHE.delete(key); HOST_TOPIC_DATA_CACHE.delete(key); cancelTopicSnapshotWrites(key); return; } if (action === 'map-clear') { clearPersistentCacheMaps(message.keys); return; } if (action === 'response-write' || action === 'response-delete') { const token = String(message.token || ''); for (const id of RESPONSE_CACHE_MEMORY.keys()) { if (token && sharedCacheIdToken(id) === token) RESPONSE_CACHE_MEMORY.delete(id); } return; } if (action === 'response-clear') { const kindSet = new Set(Array.isArray(message.kinds) ? message.kinds : []); const tagSet = new Set(Array.isArray(message.tags) ? message.tags : []); RESPONSE_CACHE_MEMORY.forEach((entry, id) => { if (message.all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag))) { RESPONSE_CACHE_MEMORY.delete(id); } }); } } function readTopicHistory() { try { const data = JSON.parse(localStorage.getItem(LDP_HISTORY_KEY) || '[]'); if (!Array.isArray(data)) return []; const config = PERSISTENT_CACHE_CONFIG.history; const safe = data.filter((entry) => entry && Number(entry.topicId) > 0 && Number(entry.viewedAt || 0) >= Date.now() - config.maxAge); if (safe.length !== data.length) return writeTopicHistory(safe); return safe; } catch (e) { return []; } } function historyEntryFirstViewedAt(entry) { return Number(entry && (entry.firstViewedAt || entry.viewedAt)) || 0; } function historyEntrySortTime(entry) { return PREFS.historySortMode === 'recent-viewed' ? Number(entry && entry.viewedAt) || 0 : historyEntryFirstViewedAt(entry); } function orderedTopicHistory() { return readTopicHistory().slice().sort((left, right) => historyEntrySortTime(right) - historyEntrySortTime(left) || Number(right.topicId || 0) - Number(left.topicId || 0) ); } function writeTopicHistory(entries) { const safeEntries = Array.isArray(entries) ? entries.slice() : []; while (true) { try { if (safeEntries.length) localStorage.setItem(LDP_HISTORY_KEY, JSON.stringify(safeEntries)); else localStorage.removeItem(LDP_HISTORY_KEY); return safeEntries; } catch (error) { if (!isStorageQuotaError(error) || !safeEntries.length) return safeEntries; safeEntries.pop(); } } } function rememberTopic(topic, postNumber = 0) { const topicId = Number(topic && topic.id); if (!topicId) return; const history = readTopicHistory(); const previousEntry = history.find((item) => Number(item.topicId) === topicId); const viewedPostNumber = Math.max( 1, Math.floor(Number(postNumber) || Number(previousEntry && previousEntry.postNumber) || 1), ); const posts = topic && topic.post_stream && Array.isArray(topic.post_stream.posts) ? topic.post_stream.posts : []; const firstPost = posts.find((post) => Number(post && post.post_number) === 1); const viewedAt = Date.now(); const entry = { topicId, title: String(topic.title || `帖子 #${topicId}`), postsCount: Math.max(0, Number(topic.posts_count) || 0), avatarTemplate: String( (topic.details && topic.details.created_by && topic.details.created_by.avatar_template) || (firstPost && firstPost.avatar_template) || '' ), ownerUsername: String( (topic.details && topic.details.created_by && topic.details.created_by.username) || (firstPost && firstPost.username) || topic._opUsername || '' ), postNumber: viewedPostNumber, firstViewedAt: historyEntryFirstViewedAt(previousEntry) || viewedAt, viewedAt, }; writeTopicHistory([entry, ...history.filter((item) => Number(item.topicId) !== topicId)]); const readerShell = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell; if (readerShell && typeof readerShell.refreshHistoryPanel === 'function') { readerShell.refreshHistoryPanel(); } } function normalizeReaderHistoryViewport(value) { const source = value && typeof value === 'object' ? value : { postNumber: value }; const postNumber = Math.floor(Number(source.postNumber)); if (!(postNumber > 0)) return null; const postOffset = Number(source.postOffset); const scrollTop = Number(source.scrollTop); return { postNumber, postOffset: Number.isFinite(postOffset) ? postOffset : 0, scrollTop: Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0, }; } function normalizeReaderHistoryPositions(values) { const positions = {}; if (!values || typeof values !== 'object') return positions; Object.entries(values).forEach(([entryTopicId, value]) => { const safeTopicId = Number(entryTopicId); const viewport = normalizeReaderHistoryViewport(value); if (safeTopicId > 0 && viewport) positions[safeTopicId] = viewport; }); return positions; } function normalizeHistoryEdgeTriggerPercent(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return HISTORY_EDGE_TRIGGER_DEFAULT; return Math.min(HISTORY_EDGE_TRIGGER_MAX, Math.max(HISTORY_EDGE_TRIGGER_MIN, Math.round(numeric))); } function createReaderHistoryNavigation(topicId, source) { const currentTopicId = Number(topicId); const normalize = (values) => Array.from(new Set((Array.isArray(values) ? values : []) .map(Number).filter((id) => id > 0 && id !== currentTopicId))); if (source && (Array.isArray(source.back) || Array.isArray(source.forward))) { return { back: normalize(source.back), forward: normalize(source.forward), positions: normalizeReaderHistoryPositions(source.positions), }; } const orderedTopicIds = orderedTopicHistory().map((entry) => Number(entry.topicId)).filter((id) => id > 0); const currentIndex = orderedTopicIds.indexOf(currentTopicId); const useFixedPosition = PREFS.historySortMode === 'first-viewed' && currentIndex >= 0; return { back: normalize(useFixedPosition ? orderedTopicIds.slice(currentIndex + 1) : orderedTopicIds), forward: normalize(useFixedPosition ? orderedTopicIds.slice(0, currentIndex).reverse() : []), positions: {}, }; } function bindReaderHistoryNavigation(overlay, topicId, navigation) { const backButton = overlay.querySelector('.ldp-reader-history-back'); const forwardButton = overlay.querySelector('.ldp-reader-history-forward'); if (!backButton || !forwardButton) return () => {}; const backEdge = backButton.closest('.ldp-reader-history-edge'); const forwardEdge = forwardButton.closest('.ldp-reader-history-edge'); const modal = overlay.querySelector('.ldp-modal'); const historyEventBindings = []; const clearActiveEdge = () => { backEdge?.classList.remove('is-active'); forwardEdge?.classList.remove('is-active'); }; const syncVisibilityPreference = () => { const alwaysVisible = PREFS.historyButtonsAlwaysVisible === true; overlay.classList.toggle('ldp-history-buttons-always-visible', alwaysVisible); if (alwaysVisible) clearActiveEdge(); }; const onModalPointerMove = (event) => { if (!modal || PREFS.historyButtonsAlwaysVisible === true || (event.pointerType && event.pointerType !== 'mouse')) { clearActiveEdge(); return; } const target = event.target instanceof Element ? event.target : null; if (target?.closest('.ldp-settings-popover,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover')) { clearActiveEdge(); return; } const rect = modal.getBoundingClientRect(); const triggerWidth = rect.width * normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent) / 100; const overBackButton = target?.closest('.ldp-reader-history-back') === backButton; const overForwardButton = target?.closest('.ldp-reader-history-forward') === forwardButton; backEdge?.classList.toggle('is-active', triggerWidth > 0 && (event.clientX <= rect.left + triggerWidth || overBackButton)); forwardEdge?.classList.toggle('is-active', triggerWidth > 0 && (event.clientX >= rect.right - triggerWidth || overForwardButton)); }; const onModalPointerLeave = () => clearActiveEdge(); if (modal) addTrackedEventListener(historyEventBindings, modal, 'pointermove', onModalPointerMove); if (modal) addTrackedEventListener(historyEventBindings, modal, 'pointerleave', onModalPointerLeave); overlay._ldpSyncHistoryButtonVisibilityPreference = syncVisibilityPreference; syncVisibilityPreference(); backButton.setAttribute('aria-keyshortcuts', 'ArrowLeft'); forwardButton.setAttribute('aria-keyshortcuts', 'ArrowRight'); const topicTitle = (targetTopicId) => { const entry = readTopicHistory().find((item) => Number(item.topicId) === Number(targetTopicId)); return entry && entry.title ? entry.title : `帖子 #${targetTopicId}`; }; const refreshVisibleTooltip = (button) => { button.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }; const sync = () => { // The reader shell can outlive a topic session; its prior navigation may still be busy. backButton.disabled = false; forwardButton.disabled = false; const backTarget = navigation.back[0]; const forwardTarget = navigation.forward[0]; backButton.hidden = !backTarget; forwardButton.hidden = !forwardTarget; if (backEdge) backEdge.hidden = !backTarget; if (forwardEdge) forwardEdge.hidden = !forwardTarget; if (backTarget) { const label = `上一条历史:${topicTitle(backTarget)}`; backButton.setAttribute('aria-label', label); } if (forwardTarget) { const label = `下一条历史:${topicTitle(forwardTarget)}`; forwardButton.setAttribute('aria-label', label); } refreshVisibleTooltip(backButton); refreshVisibleTooltip(forwardButton); }; const navigate = (direction) => { const backward = direction === 'back'; const source = backward ? navigation.back : navigation.forward; const targetTopicId = source[0]; if (!targetTopicId) return; backButton.disabled = true; forwardButton.disabled = true; const currentPostNumber = Number(overlay.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')) || 1; const currentViewport = normalizeReaderHistoryViewport( typeof overlay._ldpCaptureHistoryViewport === 'function' ? overlay._ldpCaptureHistoryViewport() : { postNumber: currentPostNumber, scrollTop: overlay.querySelector('.ldp-body')?.scrollTop || 0 } ); const positions = { ...navigation.positions, [Number(topicId)]: currentViewport || normalizeReaderHistoryViewport(currentPostNumber), }; const targetViewport = normalizeReaderHistoryViewport(positions[targetTopicId]); const nextNavigation = backward ? { back: navigation.back.slice(1), forward: [Number(topicId), ...navigation.forward], positions } : { back: [Number(topicId), ...navigation.back], forward: navigation.forward.slice(1), positions }; openModal(targetTopicId, { targetPostNumber: targetViewport ? targetViewport.postNumber : 0, restoreViewport: targetViewport || undefined, historyNavigation: nextNavigation, settleTargetJump: true, }); }; const onBackClick = () => navigate('back'); const onForwardClick = () => navigate('forward'); const onBackHover = () => refreshVisibleTooltip(backButton); const onForwardHover = () => refreshVisibleTooltip(forwardButton); addTrackedEventListener(historyEventBindings, backButton, 'click', onBackClick); addTrackedEventListener(historyEventBindings, forwardButton, 'click', onForwardClick); addTrackedEventListener(historyEventBindings, backButton, 'pointerenter', onBackHover); addTrackedEventListener(historyEventBindings, forwardButton, 'pointerenter', onForwardHover); const onHistoryKeyDown = (event) => { if (CURRENT_OVERLAY !== overlay || !overlay.isConnected || event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || readerPortalQuery('.ldp-lightbox')) return; const button = event.key === 'ArrowLeft' ? backButton : event.key === 'ArrowRight' ? forwardButton : null; if (!button || button.hidden || button.disabled) return; const target = event.target instanceof Element ? event.target : null; if (target && target !== document.body && target !== document.documentElement && !overlay.contains(target)) return; if (target && (target.isContentEditable || target.closest('input,textarea,select,.ldp-topic-timeline-track'))) return; event.preventDefault(); event.stopPropagation(); button.click(); }; addTrackedEventListener(historyEventBindings, document, 'keydown', onHistoryKeyDown); const refreshNavigationOrder = () => { const nextNavigation = createReaderHistoryNavigation(topicId); navigation.back = nextNavigation.back; navigation.forward = nextNavigation.forward; sync(); }; overlay._ldpRefreshReaderHistoryNavigation = refreshNavigationOrder; sync(); return () => { clearActiveEdge(); removeTrackedEventListeners(historyEventBindings); if (overlay._ldpRefreshReaderHistoryNavigation === refreshNavigationOrder) { delete overlay._ldpRefreshReaderHistoryNavigation; } if (overlay._ldpSyncHistoryButtonVisibilityPreference === syncVisibilityPreference) { delete overlay._ldpSyncHistoryButtonVisibilityPreference; } }; } function cachePayload(key) { if (key === 'history') return readTopicHistory(); if (key === 'topics') return Array.from(TOPIC_NAV_ICON_CACHE.entries()); const cache = cacheMapForKey(key); if (cache) return Array.from(cache.entries()); return []; } function estimateCacheBytes(value) { try { const text = JSON.stringify(value); return text.length * 2; } catch (e) { return 0; } } async function runInBatches(values, batchSize, worker) { for (let index = 0; index < values.length; index += batchSize) { await Promise.all(values.slice(index, index + batchSize).map(worker)); } } async function cacheStorageStats(names) { const groups = Object.fromEntries(names.map((name) => [name, { count: 0, bytes: 0 }])); if (typeof caches === 'undefined') return { count: 0, bytes: 0, groups }; let count = 0; let bytes = 0; let existingNames = []; try { existingNames = await caches.keys(); } catch (error) { return { count, bytes, groups }; } for (const name of names.filter((entry) => existingNames.includes(entry))) { try { const storage = await caches.open(name); const requests = await storage.keys(); let groupBytes = 0; await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => { const response = await storage.match(request); if (!response) return; let size = Number(response.headers.get('content-length')); if (!(Number.isFinite(size) && size >= 0)) { try { size = (await response.blob()).size; } catch (error) { size = 0; } } groupBytes += size; }); groups[name] = { count: requests.length, bytes: groupBytes }; count += groups[name].count; bytes += groupBytes; } catch (error) {} } return { count, bytes, groups }; } async function cacheTypeStats(key, responseStatsRequest) { if (key === 'assets') { return cacheStorageStats(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => cacheName)); } const payload = cachePayload(key); const responseKind = ['topics', 'users', 'notifications', 'responses'].includes(key) ? key : ''; const responseStat = responseKind ? (await responseStatsRequest)[responseKind] : { count: 0, bytes: 0 }; const payloadBytes = Array.isArray(payload) && !payload.length ? 0 : estimateCacheBytes(payload); const stat = { count: (Array.isArray(payload) ? payload.length : 0) + responseStat.count, bytes: payloadBytes + responseStat.bytes, }; if (key === 'history') { stat.topicCount = new Set(payload.map((entry) => Number(entry && entry.topicId)).filter(Boolean)).size; stat.historyRecordCount = payload.length; } else if (key === 'topics') { const topicNavIcons = Array.from(TOPIC_NAV_ICON_CACHE.entries()); const topicIds = new Set(responseStat.topicIds || []); stat.topicCount = topicIds.size; stat.postCount = Math.max(0, Number(responseStat.postCount) || 0); stat.topicSnapshotCount = Math.max(0, Number(responseStat.topicSnapshotCount) || 0); stat.topicRequestCount = Math.max(0, Number(responseStat.topicRequestCount) || 0); stat.topicNavIconCount = topicNavIcons.length; } else if (key === 'users') { const userNames = new Set(responseStat.userNames || []); payload.forEach(([username, entry]) => { const resolvedUsername = cleanUsernameValue(entry && entry.user && entry.user.username || username) .toLocaleLowerCase(); if (resolvedUsername) userNames.add(resolvedUsername); }); stat.userCount = userNames.size; stat.userProfileCount = payload.length; stat.userRequestCount = responseStat.count; } else if (key === 'notifications') { const notificationIds = new Set(responseStat.notificationIds || []); payload.forEach(([, entry]) => { const notifications = entry && entry.value && Array.isArray(entry.value.notifications) ? entry.value.notifications : []; addNotificationStatsIdentities(notificationIds, notifications); }); stat.notificationCount = notificationIds.size; stat.notificationPageCount = payload.length; stat.notificationRequestCount = responseStat.count; } else if (key === 'responses') { stat.responseRecords = responseStat.responseRecords || {}; } return stat; } function formatCacheBytes(bytes) { const value = Math.max(0, Number(bytes) || 0); if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(1)} MB`; } function formatCacheTypeStats(key, stat) { const config = PERSISTENT_CACHE_CONFIG[key]; const storageSuffix = config ? ' · 本机保存' : ''; if (key === 'history') { return `${stat.topicCount || 0} 个主题 · ${stat.historyRecordCount || 0} 条浏览记录 · ` + `${formatCacheBytes(stat.bytes)}${storageSuffix}`; } if (key === 'topics') { return `${stat.topicCount || 0} 个主题 · 已缓存 ${stat.postCount || 0} 个楼层 · ` + `快照 ${stat.topicSnapshotCount || 0} / 接口 ${stat.topicRequestCount || 0} / ` + `图标 ${stat.topicNavIconCount || 0} · ` + `共 ${stat.count || 0} 条记录 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`; } if (key === 'users') { return `${stat.userCount || 0} 个用户 · 资料卡 ${stat.userProfileCount || 0} / ` + `接口 ${stat.userRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` + `${formatCacheBytes(stat.bytes)}${storageSuffix}`; } if (key === 'notifications') { return `${stat.notificationCount || 0} 条消息 · 分页 ${stat.notificationPageCount || 0} / ` + `接口 ${stat.notificationRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` + `${formatCacheBytes(stat.bytes)}${storageSuffix}`; } if (key === 'responses') { const records = stat.responseRecords || {}; return `收藏 ${records.bookmarks || 0} / 回应 ${records.reactions || 0} / ` + `表情表 ${records.emojiRegistry || 0} / 其他 ${records.other || 0} · ` + `共 ${stat.count || 0} 条响应 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`; } if (key === 'assets') { const groups = stat.groups || {}; const resourceSummary = (label, cacheName) => { const group = groups[cacheName] || { count: 0, bytes: 0 }; return `${label} ${group.count || 0} 个(${formatCacheBytes(group.bytes)})`; }; return [ ...RESOURCE_CACHE_GROUPS.map(({ label, cacheName }) => resourceSummary(label, cacheName)), `共 ${stat.count || 0} 个资源 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`, ].join(' · '); } return `${stat.count || 0} 条 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`; } function cacheRetentionLabel(key) { const config = PERSISTENT_CACHE_CONFIG[key]; if (!config) return ''; if (key === 'responses') return '保留 30 天–1 年'; if (key === 'assets') return '头像与表情 90 天 · 原图 7 天'; const days = Math.max(1, Math.round(config.maxAge / (24 * 60 * 60 * 1000))); return `最多 ${days} 天`; } async function clearSelectedCaches(keys) { const selected = new Set(Array.isArray(keys) ? keys : []); const kinds = ['topics', 'users', 'notifications', 'responses'].filter((kind) => selected.has(kind)); const mapKeys = ['topics', 'topicNavIcons', 'users', 'notifications'].filter((key) => selected.has(key) || (key === 'topicNavIcons' && selected.has('topics'))); const pendingCacheRequests = new Set([ ...(selected.has('users') ? Array.from(USER_CARD_REQUESTS.values(), (entry) => entry && entry.promise).filter(Boolean) : []), ...(selected.has('users') ? USER_FOLLOW_LIST_REQUESTS.values() : []), ...(selected.has('notifications') ? NOTIFICATION_REQUESTS.values() : []), ...(kinds.length ? RESPONSE_CACHE_REQUESTS.values() : []), ]); if (pendingCacheRequests.size) await Promise.allSettled(pendingCacheRequests); if (selected.has('history')) { try { localStorage.removeItem(LDP_HISTORY_KEY); } catch (e) {} } clearPersistentCacheMaps(mapKeys); if (selected.has('topics')) { try { localStorage.removeItem(LDP_TOPIC_NAV_ICON_CACHE_KEY); } catch (e) {} } if (selected.has('users')) { try { localStorage.removeItem(LDP_USER_CARD_CACHE_KEY); } catch (e) {} } if (selected.has('notifications')) { try { localStorage.removeItem(LDP_NOTIFICATION_CACHE_KEY); } catch (e) {} } if (selected.has('assets')) { RESOURCE_CACHE_CLEARING = true; RESOURCE_CACHE_ABORT_CONTROLLER.abort(); if (RESOURCE_CACHE_PRUNE_REQUESTS.size) { await Promise.allSettled(RESOURCE_CACHE_PRUNE_REQUESTS.values()); } const pendingResourceRequests = [ ...RESOURCE_CACHE_GROUPS.flatMap(({ requests }) => Array.from(requests.values())), ...RESOURCE_CACHE_WRITES, ]; if (pendingResourceRequests.length) { await Promise.allSettled(pendingResourceRequests); } RESOURCE_CACHE_GROUPS.forEach(({ objectUrls, requests }) => { clearResourceObjectUrlCache(objectUrls); requests.clear(); }); RESOURCE_CACHE_WRITES.clear(); RESOURCE_CACHE_PRUNE_REQUESTS.clear(); if (typeof caches !== 'undefined') { await Promise.all(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => caches.delete(cacheName).catch(() => false))); } RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController(); RESOURCE_CACHE_CLEARING = false; } if (kinds.length) await clearStoredResponseCache({ kinds }); if (SHARED_REQUEST_COORDINATOR && mapKeys.length) { SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-clear', keys: mapKeys }); } } function topicResponseCacheTags(topicId, ...topicSources) { const safeTopicId = Number(topicId); const tags = new Set(safeTopicId > 0 ? [`topic:${safeTopicId}`] : []); topicSources.filter(Boolean).forEach((topic) => { const stream = topic && topic.post_stream && Array.isArray(topic.post_stream.stream) ? topic.post_stream.stream : []; stream.forEach((postId) => { const safePostId = Number(postId); if (safePostId > 0) tags.add(`post:${safePostId}`); }); postsFromPayload(topic).forEach((post) => { const safePostId = Number(post && post.id); if (safePostId > 0) tags.add(`post:${safePostId}`); }); }); return Array.from(tags); } async function clearCurrentTopicCaches(topicId, topicData) { const key = String(topicId || ''); const cachedEntry = TOPIC_DATA_CACHE.get(key); const tags = topicResponseCacheTags(topicId, topicData, cachedEntry && cachedEntry.topic); cancelTopicSnapshotWrites(topicId); HOST_TOPIC_DATA_CACHE.delete(key); TOPIC_DATA_CACHE.delete(key); if (tags.length) await clearStoredResponseCache({ tags }); } restorePersistentCaches(); const stopSharedCacheSync = SHARED_REQUEST_COORDINATOR ? SHARED_REQUEST_COORDINATOR.subscribeCache(applySharedCacheMessage) : () => {}; scheduleIdleWork(() => { void pruneStoredResponseCache(); void prunePersistentResourceCacheOnce(LDP_AVATAR_CACHE_NAME, LDP_AVATAR_CACHE_MAX_AGE) .then(() => prunePersistentResourceCacheOnce(LDP_EMOJI_IMAGE_CACHE_NAME, LDP_EMOJI_IMAGE_CACHE_MAX_AGE)); }, 2000); window.addEventListener('pagehide', (event) => { flushPersistentCacheWrites(); void flushTopicSnapshotWrites(); if (!event.persisted) { stopSharedCacheSync(); if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.destroy(); } saveEmbeddedReaderReloadState(); }); function hostTopicRowSelectors(suffix = '', indent = ' ') { return ['tr.topic-list-item', '.topic-list tbody > tr', '.topic-list-item', '.latest-topic-list-item'] .map((selector) => `html.ldp-reader-workspace ${selector}${suffix}`).join(`,\n${indent}`); } function hostTopicCellSelectors(itemSuffix = '', tableSuffix = itemSuffix, indent = ' ') { return [`.topic-list-item > td${itemSuffix}`, `.topic-list tbody > tr > td${tableSuffix}`] .map((selector) => `html.ldp-reader-workspace ${selector}`).join(`,\n${indent}`); } /* ============ 1. 样式 ============ */ const style = document.createElement('style'); style.textContent = ` :where([class^="ldp-"],[class*=" ldp-"])[hidden]{display:none!important;} html.ldp-scroll-lock,html.ldp-scroll-lock body{overflow:hidden!important;} html[data-ldp-font-rendering="builtin"]{ --ldp-font-rendering-stroke:${READER_FONT_RENDERING_DEFAULTS.stroke}px currentcolor; --ldp-font-rendering-shadow:0 0 ${READER_FONT_RENDERING_DEFAULTS.shadow}px #7c7c7cdd;} html > .ldp-reader-portal-host[data-ldp-font-rendering="builtin"], :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"], :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"] :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]), html.ldp-reader-open[data-ldp-font-rendering="builtin"] :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control), html.ldp-reader-open[data-ldp-font-rendering="builtin"] :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control) :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]), html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body, html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body :where(*):not(i[class],pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){ font-optical-sizing:auto;font-kerning:auto;text-rendering:optimizeLegibility; -webkit-text-stroke:var(--ldp-font-rendering-stroke);text-shadow:var(--ldp-font-rendering-shadow); ${READER_FONT_RENDERING_DEFAULTS.macSmoothing ? '-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;' : ''}} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox)[data-ldp-font-rendering="builtin"] :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]), html.ldp-reader-open[data-ldp-font-rendering="builtin"] :where([data-ldp-reader-composer-root],[data-ldp-reader-composer-overflow],#reply-control) :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]), html[data-ldp-font-rendering="builtin"][data-ldp-font-rendering-host="true"] body :is(pre,pre *,code,code *,kbd,kbd *,samp,samp *,mjx-container,mjx-container *,.katex,.katex *,[class*="glyph" i],[class*="symbols" i],[class*="icon" i],[class*="fa-" i],[class*="vjs-" i]){ -webkit-text-stroke:0 transparent;text-shadow:none;} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox), :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *, html.ldp-reader-open body > [data-ldp-reader-composer-root], html.ldp-reader-open [data-ldp-reader-composer-overflow], html.ldp-reader-open #reply-control, html.ldp-reader-open #reply-control *{ scrollbar-width:thin; scrollbar-color:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent) transparent;} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar, :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar, html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar, html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar, html.ldp-reader-open #reply-control::-webkit-scrollbar, html.ldp-reader-open #reply-control *::-webkit-scrollbar{width:6px;height:6px;} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-track, :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-track, html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-track, html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-track, html.ldp-reader-open #reply-control::-webkit-scrollbar-track, html.ldp-reader-open #reply-control *::-webkit-scrollbar-track{background:transparent;} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb, :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb, html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb, html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb, html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb, html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb{ border-radius:999px;background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);} :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox)::-webkit-scrollbar-thumb:hover, :where(.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-user-card-follow-panel,.ldp-lightbox) *::-webkit-scrollbar-thumb:hover, html.ldp-reader-open body > [data-ldp-reader-composer-root]::-webkit-scrollbar-thumb:hover, html.ldp-reader-open [data-ldp-reader-composer-overflow]::-webkit-scrollbar-thumb:hover, html.ldp-reader-open #reply-control::-webkit-scrollbar-thumb:hover, html.ldp-reader-open #reply-control *::-webkit-scrollbar-thumb:hover{ background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);} html > .ldp-reader-portal-host{display:contents!important;color:var(--ldp-interface-font-color,inherit); font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);} html.ldp-route-takeover body:not([data-ldp-shadow-context-body]) > :not(.ldp-overlay):not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container):not(.ldp-user-card-fallback){visibility:hidden!important;} html.ldp-composer-host-isolated body:not([data-ldp-shadow-context-body]) > :not(.ldp-native-boost-menu):not(#reply-control):not(.composer-popup):not(#d-menu-portals):not(.fk-d-menu):not(.d-modal):not(.dialog-holder):not(#user-card):not(#group-card):not(.user-card):not(.group-card):not(.user-card-container):not(.ldp-user-card-fallback){visibility:hidden!important;} html.ldp-native-reader-trigger-visible .d-header-icons{margin-inline-end:30px!important;} html.ldp-native-reader-trigger-visible .d-header-icons .current-user{position:relative!important;} .ldp-native-reader-trigger-item{position:absolute;left:100%;top:50%;z-index:2147482999;display:flex;align-items:center; width:20px;height:20px;margin-inline-start:10px;transform:translateY(-50%);background:transparent;} .ldp-native-reader-trigger{width:20px;height:20px;display:inline-flex;align-items:center; justify-content:center;box-sizing:border-box;padding:0;border:0;border-radius:0; background:transparent;color:var(--header_primary-medium,var(--primary-medium,#777)); font:inherit;line-height:1;cursor:pointer;} .ldp-native-reader-trigger:hover{background:transparent;color:var(--primary,#222);} .ldp-overlay{position:fixed;inset:0;z-index:2147483000;display:flex; align-items:center;justify-content:center;background:transparent;pointer-events:none;} .ldp-overlay.ldp-fullpage{align-items:stretch;justify-content:stretch; background:var(--secondary,#fff);pointer-events:auto;} .ldp-native-boost-menu{position:fixed;z-index:2147483608;width:min(340px,calc(100vw - 16px)); box-sizing:border-box;padding:6px;border:1px solid var(--primary-low,#d8d8d8);border-radius:8px; background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 10px 30px rgba(0,0,0,.22);pointer-events:auto;} .ldp-native-boost-menu .discourse-boosts__input-container{display:flex;align-items:center;gap:5px;} .ldp-native-boost-menu .discourse-boosts__input{position:relative;flex:1;min-width:0;box-sizing:border-box; min-height:30px;max-height:60px;overflow:auto;padding:4px 7px;border:1px solid var(--primary-low,#ccc); border-radius:6px;background:var(--secondary,#fff);color:inherit;font-size:var(--ldp-font-base,13px);outline:none;white-space:pre-wrap;overflow-wrap:anywhere;} .ldp-native-boost-menu .discourse-boosts__input:focus{border-color:var(--tertiary,#47855f); box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-native-boost-menu .discourse-boosts__input:empty::before{content:attr(data-placeholder); position:absolute;inset-inline:7px;top:50%;transform:translateY(-50%); color:var(--primary-medium,#888);pointer-events:none;} .ldp-native-boost-menu .discourse-boosts__input img.emoji{width:16px;height:16px;margin:0 .05em;vertical-align:-.15em;} .ldp-native-boost-count{min-width:28px;font-size:var(--ldp-font-xs,10px);text-align:right;color:var(--primary-medium,#777);} .ldp-native-boost-menu .discourse-boosts__emoji-btn,.ldp-native-boost-menu .discourse-boosts__submit, .ldp-native-boost-menu .discourse-boosts__cancel{ width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;flex:none; border:0;border-radius:6px;cursor:pointer;} .ldp-native-boost-menu .discourse-boosts__emoji-btn{background:transparent;color:var(--primary-medium,#777);} .ldp-native-boost-menu .discourse-boosts__emoji-btn:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-native-boost-menu .discourse-boosts__submit{background:var(--success,#47855f);color:#fff;} .ldp-native-boost-menu .discourse-boosts__cancel{background:var(--danger-low,#f5d7d7);color:var(--danger,#c44);} .ldp-native-boost-menu button:disabled{cursor:default;opacity:.5;} .ldp-native-boost-error{display:block;margin-top:5px;font-size:var(--ldp-font-sm,11px);color:var(--danger,#c44);} .ldp-native-boost-error:empty{display:none;} .ldp-modal{position:relative;display:flex;flex-direction:column; width:min(1080px,94vw);height:86vh; border-radius:12px;overflow:hidden;font-family:var(--ldp-interface-font-family,inherit); font-size:var(--ldp-font-md,14px);font-weight:var(--ldp-interface-font-weight,400); line-height:1.65;background:var(--secondary,#fff);color:var(--ldp-interface-font-color,var(--primary,#222)); box-shadow:0 8px 24px rgba(0,0,0,.18);container:ldp-reader / inline-size;pointer-events:auto;} .ldp-fullpage .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;} html.ldp-reader-workspace{--ldp-reader-workspace-width:${READER_EMBED_DEFAULT_WIDTH}px;--ldp-reader-host-gutter:15px;} html.ldp-reader-embedded-left,html.ldp-reader-embedded-right{overflow-x:hidden;scrollbar-width:none;} html.ldp-reader-embedded-left::-webkit-scrollbar,html.ldp-reader-embedded-right::-webkit-scrollbar{display:none;} html.ldp-reader-embedded-left #d-splash,html.ldp-reader-embedded-right #d-splash{ width:calc(100vw - var(--ldp-reader-workspace-width));} html.ldp-reader-embedded-left #d-splash{left:var(--ldp-reader-workspace-width);} html.ldp-reader-embedded-right #d-splash{left:0;} html.ldp-reader-embedded-right body{box-sizing:border-box!important;width:calc(100vw - var(--ldp-reader-workspace-width))!important; min-width:var(--ldp-reader-host-min-width,${READER_HOST_MIN_WIDTH}px)!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important; overflow-x:clip!important;} html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]), html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){ box-sizing:border-box!important;min-width:var(--ldp-reader-host-min-width,${READER_HOST_MIN_WIDTH}px)!important; width:calc(100vw - var(--ldp-reader-workspace-width))!important;max-width:calc(100vw - var(--ldp-reader-workspace-width))!important; margin-left:0!important;margin-right:0!important;transform-origin:top left;overflow-x:clip!important; transition:width .24s cubic-bezier(.22,1,.36,1),transform .24s cubic-bezier(.22,1,.36,1),padding .24s cubic-bezier(.22,1,.36,1);} html.ldp-reader-embedded-left body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){ transform:translateX(var(--ldp-reader-workspace-width))!important;padding-left:var(--ldp-reader-host-gutter)!important;padding-right:0!important;} html.ldp-reader-embedded-right body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){ width:100%!important;max-width:100%!important;transform:translateX(0)!important; padding-left:0!important;padding-right:var(--ldp-reader-host-gutter)!important;} html.ldp-reader-host-anchor-restoring body > :is([data-ldp-reader-host-root="shell"],[data-ldp-reader-host-root="header"]){transition:none!important;} html.ldp-reader-embedded-left body > [data-ldp-reader-host-root="sidebar"]{ transform:translateX(calc(var(--ldp-reader-workspace-width) + var(--ldp-reader-host-gutter)))!important;} html.ldp-reader-embedded-right body > [data-ldp-reader-host-root="sidebar"]{transform:translateX(0)!important;} html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(#ember-app,#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container,.topic-list-body){ box-sizing:border-box!important;width:100%!important;min-width:0!important;max-width:100%!important;margin-left:0!important;margin-right:0!important;} html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"] :is(.d-header,header){ box-sizing:border-box!important;left:0!important;right:auto!important;width:100%!important;max-width:100%!important; border:0!important;box-shadow:none!important;} html.ldp-reader-workspace body > [data-ldp-reader-host-root="shell"]{container:ldp-reader-host / inline-size;} html.ldp-reader-workspace :is(#main-outlet,#main-outlet-wrapper,.list-container,.topic-list,.latest-topic-list,.discovery-list-container){box-sizing:border-box!important;min-width:0!important;max-width:100%!important;} html.ldp-reader-workspace .topic-list{display:block!important;width:100%!important;} html.ldp-reader-workspace .topic-list thead{display:none!important;} html.ldp-reader-workspace .topic-list tbody, html.ldp-reader-workspace .latest-topic-list, html.ldp-reader-workspace .discovery-list-container .topic-list-body{ display:grid!important;width:100%!important;min-width:0!important;max-width:100%!important;overflow-x:clip; grid-template-columns:minmax(0,1fr);grid-auto-rows:max-content;align-content:start;gap:6px;padding:8px;} html.ldp-reader-workspace .topic-list tbody > tr.topic-list-item-separator{display:none!important;} ${hostTopicRowSelectors()}{ display:grid!important;width:100%!important;box-sizing:border-box!important;min-width:0;min-height:var(--ldp-reader-native-row-height,0px)!important;height:auto!important; max-width:100%!important;overflow:hidden;grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(6.75rem,7.5rem) minmax(5rem,6rem); align-items:center;align-content:center;column-gap:12px;row-gap:3px;padding:7px 12px!important; border:1px solid var(--primary-low,#ddd)!important;border-radius:10px;background:color-mix(in srgb,var(--secondary,#fff) 92%,var(--primary-very-low,#f4f4f4)); transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out,border-color .16s ease-out;} html.ldp-reader-workspace .topic-list tbody > tr:nth-child(even), html.ldp-reader-workspace .latest-topic-list > .latest-topic-list-item:nth-child(even), html.ldp-reader-workspace .discovery-list-container .topic-list-body > .topic-list-item:nth-child(even){ background:color-mix(in srgb,var(--secondary,#fff) 55%,var(--ldp-reader-list-zebra-color,var(--tertiary,#47855f)) 45%)!important;} html.ldp-reader-workspace tr.topic-list-item:hover, html.ldp-reader-workspace .topic-list-item:hover, html.ldp-reader-workspace .latest-topic-list-item:hover{cursor:pointer!important;} html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]{ border-color:var(--tertiary,#47855f)!important;box-shadow:none!important;} html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"]::before, html.ldp-reader-workspace :is(tr.topic-list-item,.topic-list-item,.latest-topic-list-item)[data-ldp-reader-active-topic="true"] > td.main-link::before{ content:none!important;display:none!important;} html:where(.ldp-reader-open,.ldp-reader-workspace) tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link), html:where(.ldp-reader-open,.ldp-reader-workspace) .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link), html:where(.ldp-reader-open,.ldp-reader-workspace) .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:600!important;-webkit-text-stroke:.1px currentColor;} html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link), html.ldp-reader-workspace .topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link), html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line > :is(a,.title,.raw-topic-link){font-weight:550!important;-webkit-text-stroke:0;} html.ldp-reader-workspace .link-top-line [data-ldp-native-dnd]{display:inline-flex!important;position:relative!important;right:auto;top:auto;flex:0 0 20px;width:20px!important;min-width:20px!important;height:20px!important;margin:0 0 0 4px!important;padding:0!important;transform:none;vertical-align:middle;box-sizing:border-box;} html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd], html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd], html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]{ background:transparent!important;border-color:transparent!important;box-shadow:none!important;color:transparent!important;text-shadow:none!important;} html.ldp-reader-workspace tr.topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before, html.ldp-reader-workspace .topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before, html.ldp-reader-workspace .latest-topic-list-item:hover .link-top-line [data-ldp-native-dnd]::before{ content:'';position:absolute;left:50%;top:50%;width:15px;height:15px;transform:translate(-50%,-50%);background:var(--primary-medium,#777); -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat; mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.268 21a2 2 0 0 0 3.464 0'/%3E%3Cpath d='M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .08-.969'/%3E%3Cpath d='M9.5 2.5A6 6 0 0 1 18 8c0 4.499 1.411 5.956 2.738 7.326A1 1 0 0 1 20 17h-1.17'/%3E%3Cpath d='m2 2 20 20'/%3E%3C/svg%3E") center/contain no-repeat;} ${hostTopicCellSelectors()}{display:block!important;width:auto!important;min-width:0;min-height:0!important;height:auto!important;margin:0!important;padding:0!important;border:0!important;align-self:center;} ${hostTopicCellSelectors('.main-link', ':first-child')}{grid-column:1;grid-row:1;align-self:center;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2;grid-row:1;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:3;grid-row:1;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:4;grid-row:1;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:5;grid-row:1;} html.ldp-reader-workspace .topic-list-item > td.posters{display:flex!important;grid-column:2;grid-row:1;min-width:0;max-width:100%;align-items:center;justify-content:flex-end;gap:2px;overflow:visible;white-space:nowrap;} html.ldp-reader-workspace .topic-list-item > td.posters > *{flex:0 0 auto;} ${hostTopicCellSelectors('.posts', ':nth-child(3)')}{grid-column:3;grid-row:1;text-align:right;} ${hostTopicCellSelectors('.views', ':nth-child(4)')}{grid-column:4;grid-row:1;text-align:right;} ${hostTopicCellSelectors('.activity', ':nth-child(5)')}{grid-column:5;grid-row:1;text-align:right;} ${hostTopicRowSelectors(':has(> td[data-ldp-native-topic-date])')}{ grid-template-columns:minmax(0,1fr) minmax(8rem,10rem) minmax(4.5rem,5rem) minmax(10.5rem,12rem) minmax(5rem,6rem);} html.ldp-reader-workspace .topic-list-item .main-link, html.ldp-reader-workspace .topic-list-item .topic-list-data.main-link{ color:var(--ldp-host-font-color,var(--primary,#222));font-family:var(--ldp-host-font-family,inherit); font-size:var(--ldp-host-topic-title-size,var(--ldp-font-lg,15px));font-weight:var(--ldp-host-font-weight,700);line-height:1.4;} html.ldp-reader-workspace .topic-list-item .link-top-line{display:block;min-width:0;max-width:100%;white-space:normal!important;} html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) .link-top-line > :is(a,.title,.raw-topic-link){ max-width:100%;color:var(--ldp-host-font-color,var(--primary,#222))!important; font-family:var(--ldp-host-font-family,inherit)!important;font-weight:var(--ldp-host-font-weight,700)!important; white-space:normal!important;overflow-wrap:anywhere;} html.ldp-reader-workspace .topic-list-item .link-bottom-line{display:flex!important;align-items:center;flex-wrap:nowrap!important;width:max-content;max-width:none;margin-top:3px;overflow:visible;white-space:nowrap;} html.ldp-reader-workspace .topic-list-item .link-bottom-line > *{flex:0 0 auto;} html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag), html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag){ display:inline-flex!important;box-sizing:border-box!important;min-height:var(--ldp-host-label-card-height,22px);align-items:center!important; gap:var(--ldp-host-label-card-gap,3px)!important;padding-block:0!important;padding-inline:var(--ldp-host-label-card-padding,7px)!important; color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important; font-size:var(--ldp-host-label-card-font-size,11px)!important;font-weight:var(--ldp-host-font-weight,600)!important;line-height:1!important;} html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) :is(.badge-category__name,.category-name,[data-category-name]), html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(span,strong,small){ color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,system-ui,sans-serif)!important; font-weight:var(--ldp-host-font-weight,600)!important;} html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.badge-wrapper,.badge-category,.badge-category__wrapper,.discourse-tag,.topic-tag) :is(svg,img,.emoji), html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.link-bottom-line,.bottom-row) > :is(.discourse-tags,.topic-tags,.tags,.tag-list) :is(a,.discourse-tag,.topic-tag) :is(svg,img,.emoji){ width:var(--ldp-host-label-card-icon-size,14px)!important;height:var(--ldp-host-label-card-icon-size,14px)!important;flex:none!important;object-fit:contain;} html.ldp-reader-workspace .topic-list-item > td.posters .avatar{ width:var(--ldp-host-topic-avatar-size,32px)!important;height:var(--ldp-host-topic-avatar-size,32px)!important;} html.ldp-reader-workspace .topic-list-item > td.posts, html.ldp-reader-workspace .topic-list-item > td.views, html.ldp-reader-workspace .topic-list-item > td.activity, html.ldp-reader-workspace .topic-list tbody > tr > td:not(:first-child){min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;white-space:nowrap;} html.ldp-reader-workspace .topic-list-item > td.posts, html.ldp-reader-workspace .topic-list-item > td.views, html.ldp-reader-workspace .topic-list-item > td.activity, html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3), html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4), html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5), ${hostTopicCellSelectors('[data-ldp-native-topic-date]')}{display:inline-flex!important;min-width:0;align-self:start!important;align-items:baseline;justify-content:flex-end;gap:4px;line-height:1.25;} ${hostTopicCellSelectors('.posts::before', ':nth-child(3)::before')}{content:'回复';} ${hostTopicCellSelectors('.views::before', ':nth-child(4)::before')}{content:'最近回复';} ${hostTopicCellSelectors('.activity::before', ':nth-child(5)::before')}{content:'活跃';} ${hostTopicCellSelectors('[data-ldp-native-old-topic]::before')}{content:'旧帖';} html.ldp-reader-workspace .topic-list-item > td.posts::before, html.ldp-reader-workspace .topic-list-item > td.views::before, html.ldp-reader-workspace .topic-list-item > td.activity::before, html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3)::before, html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4)::before, html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5)::before, html.ldp-reader-workspace .topic-list-item > td[data-ldp-native-old-topic]::before, html.ldp-reader-workspace .topic-list tbody > tr > td[data-ldp-native-old-topic]::before{flex:none;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:650;opacity:.72;} @container ldp-reader-host (max-width:1180px){ ${hostTopicRowSelectors('', ' ')}{grid-template-columns:minmax(0,1fr) 4.5rem 7.25rem 5.5rem;grid-template-rows:auto auto;align-items:center;align-content:center;column-gap:12px;row-gap:2px;padding:7px 10px!important;} ${hostTopicRowSelectors(':has(> td[data-ldp-native-topic-date])', ' ')}{grid-template-columns:minmax(0,1fr) 4.5rem 12rem 5.5rem;} ${hostTopicCellSelectors('.main-link', ':first-child', ' ')}{grid-column:1!important;grid-row:1 / span 2!important;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(2){grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(3){grid-column:2!important;grid-row:2!important;justify-self:end!important;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(4){grid-column:3!important;grid-row:2!important;justify-self:end!important;} html.ldp-reader-workspace .topic-list tbody > tr > td:nth-child(5){grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:end!important;} html.ldp-reader-workspace .topic-list-item > td.posters{grid-column:2 / span 3!important;grid-row:1!important;justify-self:end!important;max-inline-size:none!important;overflow:visible!important;} html.ldp-reader-workspace .topic-list-item > td.posts{grid-column:2!important;grid-row:2!important;justify-self:end!important;align-self:start!important;} html.ldp-reader-workspace .topic-list-item > td.views{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;} html.ldp-reader-workspace .topic-list-item > td.activity{grid-column:4!important;grid-row:2!important;justify-self:end!important;align-self:start!important;} ${hostTopicCellSelectors('[data-ldp-native-topic-date]', '[data-ldp-native-topic-date]', ' ')}{grid-column:3!important;grid-row:2!important;justify-self:end!important;align-self:start!important;}} @container ldp-reader-host (max-width:860px){ ${hostTopicRowSelectors('', ' ')}{grid-template-columns:minmax(0,1fr) 4rem 6.5rem 5rem;column-gap:10px;padding:7px 10px!important;} ${hostTopicRowSelectors(':has(> td[data-ldp-native-topic-date])', ' ')}{grid-template-columns:minmax(0,1fr) 4rem 11.5rem 5rem;} html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-medium,24px)!important;height:var(--ldp-host-topic-avatar-size-medium,24px)!important;}} @container ldp-reader-host (max-width:680px){ ${hostTopicRowSelectors('', ' ')}{grid-template-columns:minmax(0,1fr) 3.75rem 6.25rem 4.75rem;column-gap:8px;padding:7px 8px!important;} ${hostTopicRowSelectors(':has(> td[data-ldp-native-topic-date])', ' ')}{grid-template-columns:minmax(0,1fr) 3.75rem 10.5rem 4.75rem;} html.ldp-reader-workspace .topic-list-item > td.posters .avatar{width:var(--ldp-host-topic-avatar-size-small,20px)!important;height:var(--ldp-host-topic-avatar-size-small,20px)!important;}} .ldp-topic-stats-component{display:none!important;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component){grid-template-columns:minmax(0,1fr) minmax(12rem,max-content);grid-template-rows:max-content max-content;align-items:start;align-content:start;row-gap:0;min-height:0!important;padding-block:4px!important;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link{display:contents!important;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line{display:block!important;grid-column:1;grid-row:1;align-self:center;min-width:0;width:100%;max-width:100%;padding-right:0;overflow:visible;text-overflow:clip;white-space:normal!important;box-sizing:border-box;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line::after{content:'';display:inline-block;width:20px;height:20px;margin-left:4px;vertical-align:middle;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line:has([data-ldp-native-dnd])::after{display:none;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-top-line > :is(a,.title,.raw-topic-link){display:inline!important;max-width:none;white-space:normal!important;overflow-wrap:anywhere;} html.ldp-reader-workspace .ldp-native-topic-title-tools{display:inline-flex!important;align-items:center;margin-inline-start:6px;vertical-align:middle;white-space:nowrap!important;word-break:keep-all;} html.ldp-reader-workspace .ldp-native-topic-title-tools > *{flex:none;align-self:center!important;margin-block:0!important;top:auto!important;bottom:auto!important;transform:none!important;vertical-align:middle!important;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line{display:flex!important;grid-column:1;grid-row:2;align-self:center;align-items:center;align-content:center;flex-wrap:wrap!important;gap:2px 4px;min-width:0;width:100%;max-width:100%;margin-top:0;overflow:hidden;white-space:normal;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.main-link > .link-bottom-line > :is(.discourse-tags,.topic-tags){display:contents!important;} html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters{grid-column:2!important;grid-row:1!important;align-self:center!important;justify-self:end!important;} html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){display:block!important;grid-column:2!important;grid-row:2!important;align-self:center!important;justify-self:stretch!important;min-width:0;width:100%!important;overflow:visible!important;font-size:0!important;white-space:nowrap;transform:none;} html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component)::before{content:none!important;} html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component) > :not(.ldp-topic-stats-component), html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.views, html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.activity{display:none!important;} html.ldp-reader-workspace .ldp-topic-stats-component{display:flex!important;flex-direction:column;align-items:stretch;justify-content:center;row-gap:0;width:100%;min-width:0;color:var(--ldp-host-font-color,inherit);font-family:var(--ldp-host-font-family,inherit);font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px));font-weight:var(--ldp-host-font-weight,400);line-height:1;white-space:nowrap;} html.ldp-reader-workspace .ldp-topic-stats-row{display:flex;align-items:baseline;justify-content:flex-end;column-gap:0;width:100%;min-width:0;} html.ldp-reader-workspace .ldp-topic-stats-row + .ldp-topic-stats-row{margin-top:var(--ldp-host-topic-stats-row-offset,-4px);} html.ldp-reader-workspace .ldp-topic-stat{display:inline-flex;flex:none;align-items:baseline;justify-content:flex-start;gap:4px;min-width:0;white-space:nowrap;} html.ldp-reader-workspace :is(.ldp-topic-stats-row,.ldp-topic-stat,.ldp-topic-stat-value,.ldp-topic-stat-value *){line-height:1!important;} html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat{position:relative;margin-left:10px;padding-left:11px;} html.ldp-reader-workspace .ldp-topic-stat + .ldp-topic-stat::before{content:'';position:absolute;left:0;bottom:0;width:var(--ldp-divider-line-width,1px);height:1.1em;background:var(--ldp-divider-line-color,var(--primary-low,#e5e5e5));} html.ldp-reader-workspace .ldp-topic-stat-label{flex:none;color:var(--ldp-host-font-color,var(--primary-medium,#777))!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-label-size,var(--ldp-font-micro,9px));font-weight:var(--ldp-host-font-weight,650)!important;opacity:.72;} html.ldp-reader-workspace .ldp-topic-stat-value{display:inline-flex;align-items:baseline;min-width:0;color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:var(--ldp-host-topic-stats-size,var(--ldp-font-xs,10px))!important;font-weight:var(--ldp-host-font-weight,400)!important;white-space:nowrap;} html.ldp-reader-workspace .ldp-topic-stat-value *{color:var(--ldp-host-font-color,inherit)!important;font-family:var(--ldp-host-font-family,inherit)!important;font-size:inherit!important;font-weight:var(--ldp-host-font-weight,400)!important;} html.ldp-reader-workspace .ldp-topic-stat-value :is(svg,img){flex:none;align-self:baseline;} @container ldp-reader-host (max-width:1180px){ html.ldp-reader-workspace .topic-list-item:has(> td.posts > .ldp-topic-stats-component) > td.posters, html.ldp-reader-workspace .topic-list-item > td.posts:has(> .ldp-topic-stats-component){grid-column:2!important;} } .ldp-overlay.ldp-reader-embedded{align-items:stretch;justify-content:stretch;pointer-events:none;} .ldp-reader-embedded .ldp-modal{position:fixed;top:0;bottom:0;width:var(--ldp-reader-workspace-width);height:100vh; border-radius:0;box-shadow:0 8px 24px rgba(0,0,0,.18);will-change:width,clip-path,opacity; transition:width .24s cubic-bezier(.22,1,.36,1),left .24s cubic-bezier(.22,1,.36,1),right .24s cubic-bezier(.22,1,.36,1);} .ldp-reader-embedded-left .ldp-modal{left:0;right:auto;border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); animation:ldp-reader-embed-in-left .24s cubic-bezier(.22,1,.36,1) both;} .ldp-reader-embedded-right .ldp-modal{left:auto;right:0;border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); animation:ldp-reader-embed-in-right .24s cubic-bezier(.22,1,.36,1) both;} @keyframes ldp-reader-embed-in-left{from{opacity:.48;clip-path:inset(0 100% 0 0);}to{opacity:1;clip-path:inset(0);}} @keyframes ldp-reader-embed-in-right{from{opacity:.48;clip-path:inset(0 0 0 100%);}to{opacity:1;clip-path:inset(0);}} .ldp-reader-embedded-left .ldp-reader-window-capsule{display:flex;top:8px;left:calc(var(--ldp-reader-workspace-width) / 2);right:auto; transform:translate(-50%,0);} .ldp-reader-embedded-right .ldp-reader-window-capsule{display:flex;top:8px;left:auto;right:calc(var(--ldp-reader-workspace-width) / 2); transform:translate(50%,0);} .ldp-reader-embedded .ldp-reader-pin-button,.ldp-reader-embedded .ldp-reader-lock-button, .ldp-reader-embedded .ldp-reader-resize-handle{display:none!important;} .ldp-reader-embedded .ldp-reader-drag-handle{display:flex!important;pointer-events:none;cursor:default;} .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule{ position:fixed;display:flex;top:8px;left:50%;right:auto;width:40px;transform:translate(-50%,0);} .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:hover, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule:focus-within, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule.ldp-reader-placement-expanded, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-window-capsule.ldp-reader-placement-available:has(.ldp-reader-placement-control:focus-within){ width:116px;transform:translate(-50%,0);} .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-drag-handle, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-pin-button, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-lock-button, .ldp-overlay[data-reader-list-mode-allowed="true"].ldp-fullpage .ldp-reader-resize-handle{display:none!important;} .ldp-reader-embed-resize{position:fixed;top:0;bottom:0;z-index:15;display:none;width:10px;cursor:col-resize;pointer-events:auto;touch-action:none;} .ldp-reader-embedded-left .ldp-reader-embed-resize{left:calc(var(--ldp-reader-workspace-width) - 5px);} .ldp-reader-embedded-right .ldp-reader-embed-resize{right:calc(var(--ldp-reader-workspace-width) - 5px);} .ldp-reader-embedded .ldp-reader-embed-resize{display:block;} .ldp-reader-host-scrollbar{position:fixed;top:0;bottom:0;right:calc(var(--ldp-reader-workspace-width) + 6px);z-index:14; display:none;width:6px;padding:0;border:0;outline:0;background:transparent;pointer-events:auto;touch-action:none;user-select:none; opacity:1;transition:opacity .12s ease-out;} .ldp-reader-embedded .ldp-reader-host-scrollbar{display:block;} .ldp-reader-embedded-left .ldp-reader-host-scrollbar{right:6px;} .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-inactive{opacity:0;pointer-events:none;} .ldp-reader-host-scrollbar-thumb{position:absolute;left:0;top:0;width:6px;min-height:24px;border-radius:999px; background:color-mix(in srgb,var(--primary-medium,#888) 34%,transparent);will-change:height,transform;} .ldp-reader-host-scrollbar:hover .ldp-reader-host-scrollbar-thumb, .ldp-reader-host-scrollbar:focus-visible .ldp-reader-host-scrollbar-thumb, .ldp-reader-host-scrollbar.ldp-reader-host-scrollbar-dragging .ldp-reader-host-scrollbar-thumb{ background:color-mix(in srgb,var(--primary-medium,#888) 48%,transparent);} .ldp-reader-host-top{position:fixed;z-index:16;display:inline-flex;align-items:center;justify-content:center; width:30px;height:30px;padding:0;border:1px solid color-mix(in srgb,var(--primary-low,#ddd) 72%,transparent);border-radius:50%; background:color-mix(in srgb,var(--secondary,#fff) 68%,transparent);color:color-mix(in srgb,var(--primary-medium,#777) 70%,transparent); box-shadow:0 3px 10px rgba(0,0,0,.1);backdrop-filter:blur(3px);pointer-events:auto;cursor:pointer;} .ldp-reader-host-top:hover{border-color:color-mix(in srgb,var(--primary-medium,#999) 78%,transparent); background:color-mix(in srgb,var(--primary-very-low,#f0f0f0) 86%,transparent);color:var(--primary,#222);} .ldp-reader-host-top-countdown{position:absolute;right:2px;bottom:1px;font-size:7px;font-style:italic; font-weight:400;line-height:1;color:currentColor;opacity:.42;pointer-events:none;} html.ldp-reader-embed-resizing #ember-app{will-change:width,margin-left,margin-right;} html.ldp-reader-embed-resizing #ember-app,html.ldp-reader-embed-resizing .d-header, html.ldp-reader-embed-resizing body > header,html.ldp-reader-embed-resizing #ember-app > header, .ldp-overlay.ldp-reader-embed-resizing .ldp-modal{transition:none!important;} .ldp-overlay.ldp-window-managed:not(.ldp-fullpage){align-items:flex-start;justify-content:flex-start;} .ldp-overlay.ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-pinned){pointer-events:auto;} .ldp-overlay.ldp-reader-switch-ready{cursor:pointer;} html .ldp-reader-switch-target{border-radius:4px;outline:1px solid color-mix(in srgb,var(--tertiary,#47855f) 58%,transparent); outline-offset:3px;background:color-mix(in srgb,var(--tertiary-low,#dceee2) 64%,transparent)!important; color:var(--tertiary,#47855f)!important;cursor:pointer!important; transition:background-color .16s ease-out,color .16s ease-out,outline-color .16s ease-out;} .ldp-overlay.ldp-window-managed:not(.ldp-fullpage) .ldp-modal{position:absolute;} .ldp-reader-window-capsule{position:absolute;z-index:14;left:var(--ldp-reader-window-center-x,50%); top:var(--ldp-reader-window-top,50%);transform:translate(-50%,-50%);display:none; width:64px;height:20px;box-sizing:border-box;align-items:center;justify-content:center;gap:2px;padding:0 4px; border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--secondary,#fff) 88%,var(--primary-low,#ddd)); color:var(--primary-medium,#777);box-shadow:none;pointer-events:auto; transition:width .16s ease-out,height .16s ease-out,padding .16s ease-out,gap .16s ease-out, transform .16s ease-out,background .16s ease-out,box-shadow .16s ease-out,opacity .16s ease-out;} .ldp-reader-window-capsule:not(.ldp-reader-placement-available){width:40px;} .ldp-reader-window-capsule:hover,.ldp-reader-window-capsule:focus-within{width:116px;height:26px;overflow:visible; transform:translate(-50%,calc(-100% + 10px));gap:2px;padding:2px 5px;background:var(--secondary,#fff); box-shadow:0 2px 8px rgba(0,0,0,.22);opacity:1;transition-delay:0s;} .ldp-reader-window-capsule:not(:hover):not(:focus-within){opacity:.3; background:color-mix(in srgb,var(--secondary,#fff) 68%,transparent);box-shadow:0 1px 4px rgba(0,0,0,.1); outline:1px solid color-mix(in srgb,var(--primary-medium,#777) 24%,transparent);} .ldp-reader-window-capsule.ldp-reader-placement-available:has(.ldp-reader-placement-control:focus-within), .ldp-overlay:not(.ldp-reader-embedded) .ldp-reader-window-capsule.ldp-reader-placement-expanded{width:204px;} .ldp-reader-embedded .ldp-reader-window-capsule:hover, .ldp-reader-embedded .ldp-reader-window-capsule:focus-within{width:60px;} .ldp-reader-embedded .ldp-reader-window-capsule.ldp-reader-placement-expanded, .ldp-reader-embedded .ldp-reader-window-capsule.ldp-reader-placement-available:has(.ldp-reader-placement-control:focus-within){width:116px;} .ldp-reader-embedded-left .ldp-reader-window-capsule:hover, .ldp-reader-embedded-left .ldp-reader-window-capsule:focus-within{transform:translate(-50%,0);} .ldp-reader-embedded-right .ldp-reader-window-capsule:hover, .ldp-reader-embedded-right .ldp-reader-window-capsule:focus-within{transform:translate(50%,0);} .ldp-window-handle-docked .ldp-reader-window-capsule:hover, .ldp-window-handle-docked .ldp-reader-window-capsule:focus-within{transform:translate(-50%,-10px);} .ldp-reader-drag-handle{display:flex;width:32px;height:10px;flex:none;align-items:center;justify-content:center; padding:0;border:0;background:transparent;color:inherit;cursor:move;touch-action:none; transition:width .16s ease-out,height .16s ease-out,background .16s ease-out, opacity .16s ease-out,visibility 0s linear .16s;} .ldp-reader-window-capsule:hover .ldp-reader-drag-handle, .ldp-reader-window-capsule:focus-within .ldp-reader-drag-handle{width:38px;height:22px;transition-delay:0s;} .ldp-reader-embedded .ldp-reader-window-capsule:hover .ldp-reader-drag-handle, .ldp-reader-embedded .ldp-reader-window-capsule:focus-within .ldp-reader-drag-handle{ width:0;height:0;flex-basis:0;opacity:0;visibility:hidden;overflow:hidden;padding:0;transition-delay:0s;} .ldp-reader-drag-handle .ldp-icon{width:25px;height:12px;pointer-events:none;} .ldp-reader-lock-button,.ldp-reader-pin-button{display:flex;width:20px;height:20px;align-items:center;justify-content:center; padding:0;border:0;border-radius:4px; background:transparent;color:var(--primary-medium,#777);cursor:pointer; transition:width .16s ease-out,opacity .16s ease-out,transform .16s ease-out,visibility 0s linear .16s;} .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-lock-button, .ldp-reader-window-capsule:not(:hover):not(:focus-within) .ldp-reader-pin-button{ width:0;opacity:0;visibility:hidden;pointer-events:none;transform:scale(.75);} .ldp-reader-window-capsule.ldp-reader-placement-available:not(:hover):not(:focus-within) .ldp-reader-drag-handle{ width:16px;height:20px;} .ldp-reader-window-capsule.ldp-reader-placement-available:not(:hover):not(:focus-within) .ldp-reader-drag-handle .ldp-icon{ width:14px;} .ldp-reader-window-capsule:hover .ldp-reader-lock-button, .ldp-reader-window-capsule:hover .ldp-reader-pin-button, .ldp-reader-window-capsule:focus-within .ldp-reader-lock-button, .ldp-reader-window-capsule:focus-within .ldp-reader-pin-button{ opacity:1;visibility:visible;pointer-events:auto;transform:scale(1);transition-delay:0s;} .ldp-reader-lock-button:hover,.ldp-reader-pin-button:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-reader-lock-button.active,.ldp-reader-pin-button.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon{animation:ldp-reader-lock-state-change .18s cubic-bezier(.22,1,.36,1);} @keyframes ldp-reader-lock-state-change{0%{opacity:.55;transform:scale(.72) rotate(-12deg);}65%{opacity:1;transform:scale(1.12) rotate(4deg);}100%{opacity:1;transform:scale(1) rotate(0);}} .ldp-window-pinned .ldp-reader-window-capsule:not(:hover):not(:focus-within), .ldp-window-locked .ldp-reader-window-capsule:not(:hover):not(:focus-within){ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-window-locked .ldp-reader-drag-handle{cursor:default;} .ldp-reader-placement-control{display:flex;width:auto;height:20px;flex:0 0 auto;align-items:center;overflow:visible;} .ldp-reader-placement-trigger{display:flex;width:20px;height:20px;flex:0 0 20px;align-items:center;justify-content:center; padding:0;border:0;border-radius:4px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-reader-placement-strip{display:flex;width:0;height:20px;flex:0 0 auto;align-items:center;gap:2px;overflow:hidden; opacity:0;pointer-events:none;transform:translateX(3px);transition:width .16s ease-out,opacity .16s ease-out,transform .16s ease-out;} .ldp-reader-placement-control:focus-within .ldp-reader-placement-strip:not([hidden]), .ldp-reader-window-capsule.ldp-reader-placement-expanded .ldp-reader-placement-strip:not([hidden]){width:86px;opacity:1;pointer-events:auto;transform:translateX(0);transition-delay:0s;} .ldp-reader-placement-option{position:relative;display:flex;width:20px;height:20px;flex:0 0 20px;align-items:center;justify-content:center; padding:0;border:0;border-radius:4px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-reader-placement-trigger:hover,.ldp-reader-placement-trigger:focus-visible, .ldp-reader-placement-option:hover,.ldp-reader-placement-option:focus-visible{background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);outline:0;} .ldp-reader-placement-option::after{content:attr(data-reader-placement-label);position:absolute;top:calc(100% + 7px);right:50%;z-index:2; padding:4px 7px; opacity:0;pointer-events:none;transform:translate(50%,-2px);transition:opacity .08s linear,transform .08s ease-out;} .ldp-reader-placement-option:hover::after,.ldp-reader-placement-option:focus-visible::after{opacity:1;transform:translate(50%,0);} .ldp-reader-placement-option.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-reader-resize-handle{position:absolute;z-index:13;display:none;touch-action:none;background:transparent;} .ldp-reader-resize-handle[data-reader-resize="n"]{left:20px;right:20px;top:0;height:10px;cursor:ns-resize;} .ldp-reader-resize-handle[data-reader-resize="s"]{left:20px;right:20px;bottom:0;height:10px;cursor:ns-resize;} .ldp-reader-resize-handle[data-reader-resize="e"]{top:20px;right:0;bottom:20px;width:10px;cursor:ew-resize;} .ldp-reader-resize-handle[data-reader-resize="w"]{top:20px;bottom:20px;left:0;width:10px;cursor:ew-resize;} .ldp-reader-resize-handle[data-reader-resize="nw"], .ldp-reader-resize-handle[data-reader-resize="ne"], .ldp-reader-resize-handle[data-reader-resize="sw"], .ldp-reader-resize-handle[data-reader-resize="se"]{width:20px;height:20px;} .ldp-reader-resize-handle[data-reader-resize="nw"]{left:0;top:0;cursor:nwse-resize;} .ldp-reader-resize-handle[data-reader-resize="ne"]{right:0;top:0;cursor:nesw-resize;} .ldp-reader-resize-handle[data-reader-resize="sw"]{left:0;bottom:0;cursor:nesw-resize;} .ldp-reader-resize-handle[data-reader-resize="se"]{right:0;bottom:0;cursor:nwse-resize;} .ldp-window-managed:not(.ldp-fullpage) .ldp-reader-window-capsule, .ldp-window-managed:not(.ldp-fullpage):not(.ldp-window-locked) .ldp-reader-resize-handle{display:flex;} @media (prefers-reduced-motion:reduce){ .ldp-reader-lock-button.ldp-lock-state-changing .ldp-icon,.ldp-reader-embedded .ldp-modal{animation:none;}} .ldp-window-interacting,.ldp-window-interacting *{user-select:none!important;} .ldp-window-interacting .ldp-modal{will-change:left,top,width,height,transform;} .ldp-header{--ldp-header-logo-inset:14px;--ldp-header-reserved-right:156px; --ldp-home-logo-size:clamp(32px,3.5cqi,48px);--ldp-home-logo-box-size:calc(var(--ldp-home-logo-size) + 4px); position:relative;display:flex;align-items:center;gap:8px;box-sizing:border-box;min-height:73px; padding:6px var(--ldp-header-reserved-right) 6px var(--ldp-header-logo-inset); border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-fullpage .ldp-header{--ldp-header-logo-inset:max(12px,calc(var(--ldp-layout-left,20%) + 12px)); --ldp-header-reserved-right:max(156px,calc(var(--ldp-layout-right,8%) + 12px));} .ldp-home-logo{width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);border-radius:6px;display:inline-flex; align-items:center;justify-content:center;flex:none;} .ldp-home-logo:hover{background:var(--primary-very-low,#f0f0f0);} .ldp-logo{width:var(--ldp-home-logo-size);height:var(--ldp-home-logo-size);border-radius:50%;display:block;} .ldp-title-wrap{flex:1;min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:2px; line-height:1.2;text-align:left;} .ldp-title{margin:0;min-width:0;display:block; font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.25;text-align:left;} .ldp-title-jump{padding:0;border:none;background:transparent;color:inherit; cursor:pointer;font:inherit;text-align:left;display:inline;white-space:normal; overflow:visible;text-overflow:clip;overflow-wrap:anywhere;} .ldp-title-jump:hover{text-decoration:underline;} .ldp-title-jump:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:2px;} .ldp-title-actions{--ldp-header-action-gap:8px;position:absolute;top:7px;right:12px;z-index:2; display:inline-flex;align-items:center;gap:var(--ldp-header-action-gap);margin:0;} .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:28px;height:28px;flex:0 0 28px;align-items:center; justify-content:center;box-sizing:border-box;padding:4px;border:0;border-radius:6px;background:transparent; color:var(--primary-medium,#777);font:inherit;line-height:1;text-decoration:none;cursor:pointer;} .ldp-title-actions .ldp-topic-edit-trigger, .ldp-title-actions .ldp-open{display:inline-flex;} .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):hover{ background:var(--primary-very-low,#f0f0f0);color:var(--tertiary,#47855f);} .ldp-title-actions .ldp-close{position:relative;} .ldp-title-actions .ldp-close::before{content:"";position:absolute;top:6px;bottom:6px; left:calc(var(--ldp-header-action-gap) / -2);width:1px;background:var(--primary-low,#d7d7d7); opacity:.65;transform:translateX(-50%);pointer-events:none;} .ldp-reader-action-dialog.ldp-topic-edit-dialog{width:min(560px,100%);overflow:visible;} .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:repeat(2,minmax(250px,1fr)); gap:12px;overflow:visible;} .ldp-topic-edit-title{grid-column:1 / -1;} .ldp-topic-edit-field{display:grid;gap:4px;min-width:0;color:var(--primary-medium,#777); font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;} .ldp-topic-edit-field > input,.ldp-topic-edit-label-control{width:100%;min-width:0; box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff); color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.4;outline:none;} .ldp-topic-edit-field > input{height:38px;padding:7px 10px;} .ldp-topic-edit-field > input:focus,.ldp-topic-edit-label-control:focus-within{ border-color:var(--tertiary,#47855f);box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-topic-edit-category{position:relative;min-width:0;} .ldp-picker-trigger{display:flex;width:100%;align-items:center;gap:8px; box-sizing:border-box;border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff); color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;cursor:pointer;} .ldp-topic-edit-category-trigger{height:38px;padding:7px 9px 7px 10px;} .ldp-font-family-trigger:hover, .ldp-topic-edit-category-trigger:hover:not(:disabled){border-color:var(--primary-medium,#999);} .ldp-picker-trigger[aria-expanded="true"],.ldp-picker-trigger:focus-visible{ border-color:var(--tertiary,#47855f);outline:none;} .ldp-picker-trigger:focus-visible,.ldp-topic-edit-category-trigger[aria-expanded="true"]{ box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-topic-edit-category-trigger:disabled{cursor:default;opacity:.5;} .ldp-topic-edit-category-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;} .ldp-picker-trigger .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);} .ldp-topic-edit-category-trigger .ldp-icon{transition:transform 150ms ease;} .ldp-topic-edit-category-menu{position:absolute;z-index:3;top:calc(100% + 6px);left:0;width:100%;min-width:260px; box-sizing:border-box;padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px; background:var(--secondary,#fff);box-shadow:0 4px 8px rgba(0,0,0,.18);} .ldp-picker-search{display:flex;align-items:center;gap:7px;height:34px;padding:0 8px; box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;color:var(--primary-medium,#777);} .ldp-topic-edit-category-search{margin:1px 1px 5px;} .ldp-picker-search:focus-within{border-color:var(--tertiary,#47855f);} .ldp-picker-search .ldp-icon{width:14px;height:14px;flex:none;} .ldp-picker-search input{min-width:0;flex:1;padding:0;border:0;outline:0;box-shadow:none; background:transparent;color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-ui,12px);} .ldp-topic-edit-category-search input{height:30px;} .ldp-picker-options{display:grid;overflow-y:auto;overscroll-behavior:contain; padding-right:2px;scrollbar-width:thin; scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;} .ldp-topic-edit-category-options{gap:2px;max-height:min(300px,40vh);} .ldp-picker-option{display:grid;align-items:center;gap:9px;box-sizing:border-box;width:100%;min-height:36px; padding:4px 8px;border:0;border-radius:6px;background:transparent;font:inherit;text-align:left;cursor:pointer;} .ldp-topic-edit-category-option{grid-template-columns:10px minmax(0,1fr);color:var(--primary,#222);} .ldp-picker-option:hover,.ldp-picker-option:focus-visible{ background:var(--primary-very-low,#f2f2f2);outline:none;} .ldp-picker-option[aria-selected="true"]{ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-topic-edit-category-dot{width:8px;height:8px;border-radius:50%;background:var(--ldp-category-color,var(--primary-low,#ccc));} .ldp-picker-option-copy{display:grid;gap:1px;min-width:0;} .ldp-picker-option-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);line-height:1.3;} .ldp-picker-option-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary-medium,#777); font-size:var(--ldp-font-micro,9px);font-weight:500;line-height:1.25;} @supports (appearance:base-select){ .ldp-reader-select,.ldp-reader-select::picker(select){appearance:base-select;} .ldp-reader-select{display:flex;box-sizing:border-box;align-items:center;gap:8px;padding-inline:10px 8px; border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222); font:inherit;font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;text-align:left;text-align-last:left;cursor:pointer;} .ldp-reader-select:hover:not(:disabled){border-color:var(--primary-medium,#999);} .ldp-reader-select:focus-visible,.ldp-reader-select:open{border-color:var(--tertiary,#47855f); box-shadow:0 0 0 1px var(--tertiary,#47855f);outline:none;} .ldp-reader-select:disabled{cursor:default;opacity:.5;} .ldp-reader-select::picker-icon{width:16px;height:16px;flex:none;margin-inline-start:auto;color:var(--primary-medium,#777); transition:transform 150ms ease-out;} .ldp-reader-select:open::picker-icon{transform:rotate(180deg);} .ldp-reader-select::picker(select){inline-size:var(--ldp-select-picker-width,anchor-size(width));min-inline-size:0; max-inline-size:var(--ldp-select-picker-max-width,min(300px,calc(100vw - 24px))); max-block-size:var(--ldp-select-picker-max-height,min(300px,40vh));box-sizing:border-box;margin-block:6px;padding:5px;overflow-y:auto; overscroll-behavior:contain;scrollbar-width:thin; scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent; border:1px solid var(--primary-low,#d7d7d7);border-radius:8px; background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 4px 8px rgba(0,0,0,.18); opacity:1;visibility:visible;} .ldp-reader-select option{display:flex;box-sizing:border-box;min-height:34px;align-items:center;gap:9px;padding:4px 8px; border:0;border-radius:6px;background:transparent;color:var(--primary,#222);font:inherit;font-weight:600; line-height:1.35;white-space:nowrap;cursor:pointer;} .ldp-reader-select option:hover,.ldp-reader-select option:focus-visible{ background:var(--primary-very-low,#f2f2f2);outline:none;} .ldp-reader-select option:checked{ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-reader-select option::checkmark{display:none;} .ldp-collection-scope{--ldp-select-picker-max-height:100px;justify-content:center;gap:6px; padding-inline:6px;text-align:center;text-align-last:center;} .ldp-collection-scope::picker-icon{width:14px;height:14px;margin-inline-start:0;} .ldp-collection-scope option{min-height:30px;gap:6px;padding:3px 6px;font-size:var(--ldp-font-ui,12px);} @media (prefers-reduced-motion:reduce){.ldp-reader-select::picker-icon{transition:none;}} } .ldp-topic-edit-label-control{display:flex;align-items:center;flex-wrap:wrap;gap:4px;min-height:38px;padding:4px 6px;} .ldp-topic-edit-label-values{display:contents;} .ldp-topic-edit-label-chip{display:inline-flex;align-items:center;gap:3px;max-width:150px;padding:2px 5px; border-radius:4px;background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);font-weight:550;} .ldp-topic-edit-label-chip > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-topic-edit-label-remove{display:inline-flex;width:18px;height:18px;flex:none;align-items:center;justify-content:center; padding:2px;border:0;border-radius:4px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-topic-edit-label-remove:hover{background:var(--primary-low,#ddd);color:var(--primary,#222);} .ldp-topic-edit-label-input{flex:1 1 120px;min-width:90px;height:26px;padding:2px;border:0!important; background:transparent!important;color:inherit!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important; outline:none!important;box-shadow:none!important;} .ldp-reader-action-footer.ldp-topic-edit-actions{justify-content:flex-start;} .ldp-topic-edit-save,.ldp-topic-edit-cancel{display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center; padding:7px;border-radius:6px;font:inherit;cursor:pointer;} .ldp-topic-edit-cancel{border:1px solid var(--primary-low,#ccc);background:transparent;color:var(--primary,#222);} .ldp-topic-edit-save:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 88%,#000);} .ldp-topic-edit-cancel:hover{background:var(--primary-very-low,#f0f0f0);} .ldp-topic-edit-save:disabled,.ldp-topic-edit-cancel:disabled{cursor:default;opacity:.5;} .ldp-topic-edit-status{grid-column:1 / -1;min-width:0;min-height:17px;margin:0;color:var(--danger,#c44); font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;} .ldp-only-op-toggle{box-sizing:border-box;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center; flex:none;padding:3px;border:0;border-radius:5px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-only-op-toggle:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-only-op-toggle.active{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-only-op-toggle:disabled{opacity:.45;cursor:default;} .ldp-title-subline{display:grid;grid-template-columns:minmax(0,1fr);align-items:start;justify-items:start; gap:2px;min-width:0;text-align:left;} .ldp-meta-row{display:flex;align-items:center;min-width:0;width:100%;} .ldp-meta{display:flex;flex-wrap:wrap;align-items:center;min-width:0;font-size:var(--ldp-font-sm,11px); margin-top:0;line-height:1.2;white-space:nowrap;text-align:left;} .ldp-meta-stats,.ldp-meta-owner-copy,.ldp-meta-owner::before{opacity:.7;} .ldp-meta-owner{display:inline-flex;align-items:center;white-space:nowrap;} .ldp-meta-owner::before{content:' · ';white-space:pre;} .ldp-meta-owner .ldp-only-op-toggle{margin-left:3px;} .ldp-only-op-progress{display:inline-flex;min-width:0;align-items:center;gap:4px;margin-left:5px; color:var(--primary-medium,#68737e);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;line-height:1;} .ldp-only-op-progress-track{width:42px;height:4px;flex:none;overflow:hidden;border-radius:999px; background:color-mix(in srgb,var(--primary-low,#d7dde3) 82%,var(--secondary,#fff));} .ldp-only-op-progress-fill{display:block;width:var(--ldp-only-op-progress,0%);height:100%;border-radius:inherit; background:var(--tertiary,#47855f);transition:width .16s ease-out;} .ldp-only-op-progress[data-state="cooldown"] .ldp-only-op-progress-fill, .ldp-only-op-progress[data-state="window"] .ldp-only-op-progress-fill, .ldp-only-op-progress[data-state="waiting"] .ldp-only-op-progress-fill, .ldp-only-op-progress[data-state="retry"] .ldp-only-op-progress-fill, .ldp-only-op-progress[data-state="partial"] .ldp-only-op-progress-fill{background:var(--highlight,#e7a400);} .ldp-only-op-progress[data-state="error"] .ldp-only-op-progress-fill{background:var(--danger,#c84545);} .ldp-only-op-progress[data-state="done"] .ldp-only-op-progress-fill{background:var(--success,#3f8f5f);} .ldp-only-op-progress-value{max-width:min(255px,32vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-title-topic-row{display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap; gap:4px 8px;min-width:0;width:100%;text-align:left;} .ldp-topic-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:0;font-size:var(--ldp-font-sm,11px);line-height:1.2;} .ldp-topic-tag{display:inline-flex;align-items:center;gap:3px;max-width:96px;line-height:1.25; padding:1px 5px;border-radius:4px;background:var(--primary-very-low,#f0f0f0); color:inherit;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-topic-tag :is(svg,img){width:1em;height:1em;flex:none;object-fit:contain;} .ldp-topic-tag:hover{background:var(--primary-low,#e0e0e0);color:var(--tertiary,#08c);} .ldp-topic-category{font-weight:600;max-width:100%;} .ldp-topic-category-text{display:inline-flex;min-width:0;} .ldp-topic-category-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-topic-category-level{flex:none;} .ldp-header :is(.ldp-title,.ldp-meta-row,.ldp-topic-tags){ box-sizing:border-box;padding:1px 4px;} .ldp-head-btns{display:flex;gap:6px;align-items:center;flex:none;justify-content:flex-end;position:relative;margin-top:36px; right:calc(var(--ldp-header-logo-inset) - var(--ldp-header-reserved-right));} .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-settings-toggle{cursor:pointer;border:1px solid var(--primary-low,#ccc); background:transparent;color:inherit;border-radius:6px;padding:5px;box-sizing:border-box; width:30px;height:30px;font-size:var(--ldp-font-ui,12px);white-space:nowrap;text-decoration:none;display:inline-flex; align-items:center;justify-content:center;} .ldp-head-btns :is(.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-settings-toggle) .ldp-icon{ width:15px;height:15px;stroke-width:1.9;} .ldp-notifications-toggle{position:relative;overflow:visible;} .ldp-notification-unread-badge{position:absolute;right:-5px;top:-5px;z-index:1;min-width:15px;height:15px; box-sizing:border-box;padding:0 4px;border:2px solid var(--secondary,#fff);border-radius:999px; background:var(--danger,#d94747);color:#fff;font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:11px;text-align:center; font-variant-numeric:tabular-nums;pointer-events:none;} .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-settings-toggle span{display:none;} .ldp-layout-toggle:hover,.ldp-notifications-toggle:hover,.ldp-history-toggle:hover,.ldp-bookmarks-toggle:hover,.ldp-settings-toggle:hover{background:var(--primary-very-low,#f0f0f0);} .ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover{position:fixed;z-index:2147483606;box-sizing:border-box; width:min(420px,calc(100vw - 24px));height:min(68vh,560px);display:flex;flex-direction:column; border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff); color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit); font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 16px 46px rgba(0,0,0,.22);overflow:hidden;pointer-events:auto;} .ldp-notification-tabs{display:flex;gap:2px;padding:8px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-notification-tab{flex:1;min-width:0;border:none;border-radius:6px;padding:7px 5px; background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} .ldp-notification-tab:hover{background:var(--primary-very-low,#f3f3f3);} .ldp-notification-tab.active{background:var(--primary-low,#e8e8e8);} .ldp-notification-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px; min-height:30px;padding:5px 10px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-notification-mark-all{display:inline-flex;align-items:center;gap:5px;min-height:26px;padding:4px 7px; border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:inherit; font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;} .ldp-notification-mark-all:hover:not(:disabled){background:var(--primary-very-low,#f3f3f3);color:var(--primary,#222);} .ldp-notification-mark-all:disabled{opacity:.42;cursor:default;} .ldp-popover-search{display:flex;align-items:center;gap:7px;margin:8px 10px 6px;padding:0 9px; min-height:36px;box-sizing:border-box;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);} .ldp-popover-search:focus-within{border-color:var(--tertiary,#47855f);} .ldp-popover-search .ldp-icon{width:15px;height:15px;flex:none;color:var(--primary-medium,#777);} .ldp-popover-search-input{min-width:0;flex:1;align-self:center;height:32px;margin:0!important; border:0!important;outline:0!important;box-shadow:none!important;padding:0!important;appearance:none; background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);line-height:32px;} .ldp-popover-search-clear{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center; flex:none;border:0;border-radius:4px;padding:0;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-popover-search-clear:hover{background:var(--primary-very-low,#f3f3f3);color:inherit;} .ldp-notification-list{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:2px 0;} .ldp-collection-title{display:flex;height:47px;box-sizing:border-box;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px 8px 12px; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);font-weight:700;} .ldp-collection-title-actions{display:flex;align-items:center;gap:6px;} .ldp-collection-action{position:relative;display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;padding:0; border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777); font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;} .ldp-collection-action:hover:not(:disabled){ border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);background:var(--primary-very-low,#f3f3f3);} .ldp-history-sort-toggle[aria-pressed="true"]{border-color:var(--tertiary,#47855f); background:var(--tertiary-low,#dbeeff);color:var(--tertiary,#47855f);} .ldp-collection-action.danger:hover:not(:disabled){border-color:var(--danger,#c94747);color:var(--danger,#c94747);} .ldp-collection-action:disabled{opacity:.42;cursor:default;} .ldp-collection-count{position:absolute;right:-5px;top:-6px;display:flex;min-width:15px;height:15px;box-sizing:border-box; align-items:center;justify-content:center;padding:0 3px;border-radius:999px;background:var(--danger,#c94747);color:#fff; font-size:9px;font-weight:750;line-height:1;} .ldp-collection-scope{width:74px;height:28px;min-width:74px;flex:none;padding:3px 6px;border:1px solid var(--primary-low,#ddd); border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-base,13px); font-weight:700;line-height:1;cursor:pointer;} .ldp-bookmark-tabs{display:flex;gap:2px;margin:8px 10px 0;padding:2px;border-radius:6px;background:var(--primary-very-low,#f3f3f3);user-select:none;} .ldp-bookmark-tab{flex:1;min-width:0;border:0;border-radius:4px;padding:6px 8px;background:transparent;color:var(--primary-medium,#777); font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:default;} .ldp-bookmark-tab.ldp-bookmark-tab-dragging{cursor:grabbing;opacity:.65;} .ldp-bookmark-tab:hover{color:var(--primary,#222);} .ldp-bookmark-tab.active{background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 1px 2px rgba(0,0,0,.1);} .ldp-reaction-filters{display:flex;gap:5px;margin:0 10px 6px;overflow-x:auto;overscroll-behavior-x:contain;} .ldp-reaction-filter{display:inline-flex;min-width:30px;height:28px;flex:none;align-items:center;justify-content:center;gap:4px;padding:0 7px; border:1px solid var(--primary-low,#ddd);border-radius:6px;background:transparent;color:var(--primary-medium,#777); font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;line-height:1;cursor:pointer;} .ldp-reaction-filter:hover{border-color:var(--tertiary,#47855f);color:var(--primary,#222);background:var(--primary-very-low,#f3f3f3);} .ldp-reaction-filter.active{border-color:var(--tertiary,#47855f);background:var(--tertiary-low,#dbeeff);color:var(--primary,#222);} .ldp-reaction-filter-count{font-variant-numeric:tabular-nums;} .ldp-collection-item{position:relative;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-collection-item:first-child{border-top:none;} .ldp-collection-item .ldp-notification-item{padding-right:56px;border-top:none;} .ldp-collection-item.multi .ldp-notification-item{padding-left:42px;} .ldp-collection-item.selected .ldp-notification-item{ background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 32%,var(--secondary,#fff));} .ldp-reaction-record .ldp-notification-item{padding-right:12px;} .ldp-reaction-record-icon{display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center; margin-right:3px;font-size:14px;line-height:1;vertical-align:-3px;} .ldp-reaction-filter img.emoji,.ldp-reaction-filter img.emoji.only-emoji, .ldp-reaction-record-icon img.emoji,.ldp-reaction-record-icon img.emoji.only-emoji{width:16px;height:16px;margin:0;object-fit:contain;} .ldp-collection-select{position:absolute;z-index:1;left:12px;top:50%;transform:translateY(-50%);display:flex; width:18px;height:28px;align-items:center;justify-content:center;cursor:pointer;} .ldp-collection-select-input{width:16px;height:16px;margin:0;accent-color:var(--tertiary,#47855f);cursor:pointer;} .ldp-collection-delete{position:absolute;right:10px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center; width:32px;height:32px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff); color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer; opacity:0;pointer-events:none;} .ldp-collection-item:hover .ldp-collection-delete,.ldp-collection-item:focus-within .ldp-collection-delete{ opacity:1;pointer-events:auto;} .ldp-collection-delete:hover,.ldp-collection-delete:focus-visible{border-color:var(--danger,#c94747);color:var(--danger,#c94747);} @media (hover:none){.ldp-collection-delete{opacity:1;pointer-events:auto;}} .ldp-notification-date-label{padding:7px 12px 4px;color:var(--primary-medium,#777); font-size:var(--ldp-font-sm,11px);font-weight:700;} .ldp-notification-item{display:grid;grid-template-columns:34px minmax(0,1fr);gap:9px; align-items:start;padding:9px 12px;color:inherit;text-decoration:none;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-notification-date-group .ldp-notification-item:first-of-type{border-top:none;} .ldp-notification-item:hover{background:var(--primary-very-low,#f5f5f5);color:inherit;} .ldp-notification-item.unread{background:color-mix(in srgb,var(--tertiary-low,#dbeeff) 36%,var(--secondary,#fff));} .ldp-notification-avatar{width:32px;height:32px;border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);} .ldp-notification-avatar-fallback{display:flex;align-items:center;justify-content:center; color:var(--primary-medium,#666);font-size:var(--ldp-font-base,13px);font-weight:700;} .ldp-notification-copy{min-width:0;display:grid;gap:2px;} .ldp-notification-title{font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.35;overflow-wrap:anywhere;} .ldp-notification-footer{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0;} .ldp-notification-meta{font-size:var(--ldp-font-sm,11px);line-height:1.35;color:var(--primary-medium,#777);} .ldp-notification-read-state{flex:none;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:650;line-height:1.35;white-space:nowrap;} .ldp-notification-item.unread .ldp-notification-read-state{color:var(--tertiary,#47855f);} .ldp-notification-empty,.ldp-notification-error{display:flex;min-height:148px;align-items:center; justify-content:center;padding:18px;text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);} .ldp-notification-error{color:var(--danger,#c44);} .ldp-notification-pager{display:grid;grid-template-columns:34px minmax(0,1fr) 34px;align-items:center; gap:8px;padding:7px 9px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-notification-page-prev,.ldp-notification-page-next{width:30px;height:30px;display:inline-flex; align-items:center;justify-content:center;border:1px solid var(--primary-low,#ddd);border-radius:6px; background:transparent;color:inherit;cursor:pointer;} .ldp-notification-page-prev:disabled,.ldp-notification-page-next:disabled{opacity:.38;cursor:default;} .ldp-notification-page-info{text-align:center;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-settings-popover{--ldp-settings-frozen-height:75px;--ldp-settings-sidebar-width:160px; --ldp-font-micro:9px;--ldp-font-xs:10px;--ldp-font-sm:11px;--ldp-font-ui:12px;--ldp-font-base:13px; --ldp-font-md:14px;--ldp-font-lg:15px;--ldp-font-xl:16px;--ldp-font-2xl:17px;--ldp-font-3xl:18px; position:fixed;left:50%;top:50%;right:auto;z-index:2147483606; transform:translate(-50%,-50%);display:grid;grid-template-columns:var(--ldp-settings-sidebar-width) minmax(0,1fr); width:672px;height:520px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px); border:1px solid var(--primary-low,#ddd);border-radius:12px;background:var(--secondary,#fff); color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit); font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 24px 70px rgba(0,0,0,.24);overflow:hidden;pointer-events:auto;} .ldp-settings-popover:not([hidden]){will-change:transform;} .ldp-settings-popover.ldp-settings-window-dragging{cursor:grabbing;user-select:none;} .ldp-settings-close{position:absolute;top:12px;right:12px;z-index:2;display:inline-flex; width:32px;height:32px;align-items:center;justify-content:center;padding:0;border:0;border-radius:7px; background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-settings-close:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking){ border-color:color-mix(in srgb,var(--primary-low,#ddd) 10%,transparent); background:color-mix(in srgb,var(--secondary,#fff) 75%,transparent);box-shadow:0 24px 70px rgba(0,0,0,.024);} .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-tabs, .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-section > :not(.ldp-settings-fields):not(.ldp-color-groups):not(.ldp-settings-category-groups), .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-color-group-head, .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-category-head, .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-row:not(.ldp-range-drag-active):not(.ldp-color-pick-active), .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-settings-fields > .ldp-setting-group:not(:has(.ldp-range-drag-active,.ldp-color-pick-active)), .ldp-settings-popover:is(.ldp-range-dragging,.ldp-color-picking) .ldp-setting-group:has(.ldp-range-drag-active,.ldp-color-pick-active) > :not(.ldp-range-drag-active):not(.ldp-color-pick-active){opacity:.75;} .ldp-settings-tabs{display:flex;min-height:220px;height:100%;flex-direction:column;padding:0 12px 12px;background:var(--primary-very-low,#f7f7f7); border-right:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;} .ldp-settings-brand{display:flex;height:var(--ldp-settings-frozen-height);flex:none;box-sizing:border-box; align-items:center;justify-content:center;gap:6px;margin:0 -12px;padding:0 4px; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-high,#333);} .ldp-settings-brand-logo{display:block;width:44px;height:44px;flex:none;border-radius:50%;} .ldp-settings-brand-name{display:grid;justify-items:start;color:color-mix(in srgb,var(--tertiary,#47855f) 68%,var(--primary,#222)); font-family:inherit;font-style:normal;font-weight:750;letter-spacing:-.025em;font-kerning:normal;text-align:left;white-space:nowrap;} .ldp-settings-brand-name > span:first-child{font-size:var(--ldp-font-lg,15px);line-height:1.05;} .ldp-settings-brand-name > span:not(:first-child){font-size:var(--ldp-font-ui,12px);line-height:1.05;} .ldp-settings-nav-shell{position:relative;display:flex;min-height:0;flex:1;} .ldp-settings-nav{display:flex;width:100%;min-height:0;flex:1;flex-direction:column;padding-top:5px;overflow-y:auto; scrollbar-width:none;-ms-overflow-style:none;} .ldp-settings-nav::-webkit-scrollbar{display:none;} .ldp-settings-nav-scroll{position:absolute;right:4px;z-index:1;display:flex;width:32px;height:32px;align-items:center; justify-content:center;padding:0;border:0;background:none;color:#f2aa2e;cursor:pointer;opacity:1;} .ldp-settings-nav-scroll:hover{color:#d98600;} .ldp-settings-nav-scroll:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:-2px;} .ldp-settings-nav-scroll .ldp-icon{width:22px;height:22px;filter:drop-shadow(0 1px 0 var(--secondary,#fff));} .ldp-settings-nav-scroll-up{top:2px;} .ldp-settings-nav-scroll-down{bottom:2px;} .ldp-settings-nav-scroll-up .ldp-icon{transform:rotate(-90deg);} .ldp-settings-nav-scroll-down .ldp-icon{transform:rotate(90deg);} .ldp-settings-tab{display:flex;width:100%;align-items:center;gap:9px;border:none;background:transparent;color:inherit;cursor:default; border-radius:8px;padding:9px 10px;text-align:left;font-family:inherit; font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.25;} .ldp-settings-tab-draft-count{display:inline-flex;min-width:18px;height:18px;align-items:center;justify-content:center; box-sizing:border-box;margin-left:auto;padding:0 5px;border-radius:9px;background:var(--tertiary,#47855f);color:var(--secondary,#fff); font-size:var(--ldp-font-micro,9px);font-weight:750;line-height:1;} .ldp-settings-tab-draft-count[hidden]{display:none;} .ldp-settings-tab .ldp-icon{width:16px;height:16px;flex:none;color:var(--primary-medium,#777);} .ldp-settings-tab.active .ldp-icon{color:var(--tertiary,#47855f);} .ldp-settings-tab.active{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f); box-shadow:0 1px 4px rgba(0,0,0,.08);} .ldp-settings-tab[data-settings-panel="user"]{position:relative;margin-bottom:10px;} .ldp-settings-tab[data-settings-panel="user"]::after{content:"";position:absolute;left:0;right:0;bottom:-5px;height:1px; background:var(--ldp-divider-line-color,#e5e5e5);pointer-events:none;} .ldp-settings-footer{margin-top:auto;padding-top:10px; border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-settings-theme{display:grid;grid-template-columns:repeat(3,30px);justify-content:center;gap:4px;} .ldp-settings-theme-button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center; box-sizing:border-box;padding:6px;border:1px solid transparent;border-radius:6px;background:transparent; color:var(--primary-medium,#777);cursor:pointer;} .ldp-settings-theme-button:hover{background:var(--secondary,#fff);color:var(--primary,#222);} .ldp-settings-theme-button.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,transparent); background:var(--secondary,#fff);color:var(--tertiary,#47855f);} .ldp-settings-panel{min-width:0;box-sizing:border-box;padding:0 20px 18px; height:100%;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;-ms-overflow-style:none;} .ldp-settings-panel:has(> .ldp-settings-section[data-settings-panel="user"]:not([hidden])){overflow-y:hidden;} .ldp-settings-panel::-webkit-scrollbar{display:none;} .ldp-settings-intro{position:sticky;top:0;z-index:1;align-self:start;display:grid;gap:4px; min-height:var(--ldp-settings-frozen-height);box-sizing:border-box; margin:0 -20px;padding:18px 58px 14px 20px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); background:var(--secondary,#fff);cursor:grab;touch-action:none;user-select:none;} .ldp-settings-intro::before{content:"";position:absolute;top:7px;left:calc(50% - var(--ldp-settings-sidebar-width) / 2);width:28px;height:3px; border-radius:999px;background:var(--primary-low,#ddd);transform:translateX(-50%);} .ldp-settings-popover.ldp-settings-window-dragging .ldp-settings-intro{cursor:grabbing;} .ldp-settings-popover.ldp-settings-window-dragging .ldp-settings-intro::before{background:var(--tertiary,#47855f);} .ldp-settings-title{margin:0;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.3;color:var(--primary,#222);} .ldp-settings-description,.ldp-reader-action-intro{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.5;} .ldp-settings-panel .ldp-settings-section[data-settings-panel="user"]{gap:0;} .ldp-settings-section[data-settings-panel="user"] .ldp-settings-intro{padding-right:100px;} .ldp-user-info-last-refresh{position:absolute;right:20px;bottom:8px;color:var(--primary-medium,#777); font-size:var(--ldp-font-micro,9px);font-variant-numeric:tabular-nums;line-height:1;white-space:nowrap;} .ldp-user-info-title-refresh{position:absolute;right:52px;top:12px;display:inline-flex;width:32px;height:32px; align-items:center;justify-content:center;padding:0;border:0;border-radius:7px; background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-user-info-title-refresh:hover{background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-user-info-title-refresh:disabled{opacity:.62;cursor:wait;} .ldp-user-info-title-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;} .ldp-user-info-content{display:grid;gap:4px;padding:0 0 2px;} .ldp-user-info-tabs{display:flex;align-items:center;gap:18px;padding:0 2px;border-bottom:0;background:transparent;} .ldp-user-info-tab{position:relative;display:inline-flex;min-width:0;height:25px;align-items:center;justify-content:center;gap:5px; box-sizing:border-box;padding:0 3px;border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777); font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;} .ldp-user-info-tab .ldp-icon{width:14px;height:14px;flex:none;} .ldp-user-info-tab:hover{color:var(--primary,#222);} .ldp-user-info-tab.active{color:var(--tertiary,#47855f);} .ldp-user-info-tab.active::after{content:"";position:absolute;left:2px;right:2px;bottom:-1px;height:2px; border-radius:2px 2px 0 0;background:var(--tertiary,#47855f);} .ldp-user-info-view{padding-top:14px;} .ldp-user-info-loading,.ldp-user-info-error{display:grid;min-height:150px;place-items:center;padding:14px; border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--primary-very-low,#f7f7f7); color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;} .ldp-user-info-profile{position:relative;overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:11px; background:var(--secondary,#fff);} .ldp-user-info-cover{height:78px;background:linear-gradient(135deg, color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff)),var(--primary-very-low,#f4f4f4));} .ldp-user-info-profile-body{position:relative;display:grid;grid-template-columns:60px minmax(0,1fr);gap:9px; padding:0 13px 10px;} .ldp-user-info-avatar{display:grid;width:54px;height:54px;place-items:center;margin-top:-22px;border:3px solid var(--secondary,#fff); border-radius:50%;background:var(--primary-low,#ddd);box-shadow:0 2px 8px rgba(0,0,0,.14);font-size:var(--ldp-font-xl,16px);font-weight:750;} .ldp-user-info-avatar :is(img,.ldp-persistent-avatar-fallback){width:100%;height:100%;border-radius:50%;object-fit:cover;} .ldp-user-info-avatar .ldp-avatar-with-flair{width:100%;height:100%;} .ldp-user-info-avatar .ldp-avatar-flair{right:-3px;bottom:-2px;width:18px;height:18px;border-width:2px;} .ldp-user-info-identity{min-width:0;padding-top:6px;} .ldp-user-info-name-row{display:flex;min-width:0;align-items:center;gap:6px;} .ldp-user-info-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--primary,#222); font-size:var(--ldp-font-2xl,17px);font-weight:750;line-height:1.25;} .ldp-user-info-level{flex:none;padding:2px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2); color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;} .ldp-user-info-username{margin-top:1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);} .ldp-user-info-title{display:flex;align-items:center;gap:5px;margin-top:2px;color:var(--primary-high,#333); font-size:var(--ldp-font-ui,12px);} .ldp-user-info-title > .ldp-avatar-flair{position:static;width:14px;height:14px;flex:none;border-width:1px;} .ldp-user-info-site{display:inline-flex;max-width:100%;align-items:center;gap:5px;margin-top:3px;color:var(--tertiary,#47855f); font-size:var(--ldp-font-ui,12px);font-weight:650;text-decoration:none;} .ldp-user-info-site .ldp-icon{width:13px;height:13px;flex:none;} .ldp-user-info-site span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-user-info-bio{grid-column:1/-1;margin-top:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.4;} .ldp-user-profile-facts{display:flex;align-items:center;flex-wrap:wrap;} .ldp-user-profile-fact{display:inline-flex;min-width:0;align-items:baseline;gap:4px;} .ldp-user-profile-fact-label{flex:none;color:var(--primary-medium,#777);} .ldp-user-profile-fact-value{min-width:0;color:var(--primary,#222);overflow-wrap:anywhere;} .ldp-user-profile-fact.is-accent .ldp-user-profile-fact-value{color:var(--tertiary,#47855f);} .ldp-user-profile-facts.is-settings{grid-column:1/-1;gap:4px 12px;padding-top:7px; border-top:1px solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-sm,11px);line-height:1.35;} .ldp-user-profile-facts.is-settings .is-wide{flex-basis:100%;} .ldp-connect-card{display:grid;gap:6px;padding:8px;border:1px solid var(--primary-low,#ddd);border-radius:11px; background:var(--secondary,#fff);} .ldp-connect-head{display:flex;align-items:flex-start;justify-content:space-between;gap:9px;} .ldp-connect-heading{display:grid;gap:2px;} .ldp-connect-heading strong{color:var(--primary,#222);font-size:var(--ldp-font-base,13px);line-height:1.35;} .ldp-connect-heading small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;} .ldp-connect-status{flex:none;padding:3px 7px;border-radius:999px;background:var(--tertiary-low,#dceee2); color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;} .ldp-connect-status.is-unmet{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);} .ldp-connect-rings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px;} .ldp-connect-ring{display:grid;place-items:center;gap:2px;min-width:0;text-align:center;} .ldp-connect-ring-visual{display:grid;width:50px;height:50px;place-items:center;border-radius:50%; background:conic-gradient(var(--ldp-connect-color,var(--tertiary,#47855f)) var(--ldp-connect-progress,0%),var(--primary-low,#ddd) 0);} .ldp-connect-ring-visual::before{content:"";grid-area:1/1;width:40px;height:40px;border-radius:50%;background:var(--secondary,#fff);} .ldp-connect-ring-value{z-index:1;grid-area:1/1;display:grid;color:var(--primary,#222);font-size:var(--ldp-font-sm,11px); font-weight:400;font-variant-numeric:tabular-nums;line-height:1.2;} .ldp-connect-ring-value small{color:var(--primary-medium,#777);font-size:var(--ldp-font-micro,9px);font-weight:400;} .ldp-connect-ring-label{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;} .ldp-connect-metric{cursor:help;} .ldp-connect-detail-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;align-items:start;} .ldp-connect-group{display:grid;gap:4px;} .ldp-connect-group-title{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:400;} .ldp-connect-bars,.ldp-connect-quotas{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;} .ldp-connect-detail-groups .ldp-connect-bars, .ldp-connect-detail-groups .ldp-connect-quotas{grid-template-columns:minmax(0,1fr);} .ldp-connect-bar,.ldp-connect-quota{display:grid;gap:2px;padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;} .ldp-connect-bar.is-short{border-color:color-mix(in srgb,var(--danger,#c84545) 34%,var(--primary-low,#ddd)); background:color-mix(in srgb,var(--danger-low,#f7dddd) 24%,var(--secondary,#fff));} .ldp-connect-bar-copy{display:flex;align-items:baseline;justify-content:space-between;gap:10px; font-size:var(--ldp-font-sm,11px);line-height:1.35;} .ldp-connect-bar-copy span{color:var(--primary-high,#333);font-weight:400;} .ldp-connect-bar-copy strong,.ldp-connect-quota-copy strong{color:var(--ldp-connect-color,var(--tertiary,#47855f));font-weight:400;font-variant-numeric:tabular-nums;} .ldp-connect-bar-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#ddd);} .ldp-connect-bar-fill{position:relative;display:block;width:var(--ldp-connect-progress,0%);max-width:100%;height:100%;border-radius:inherit; background:var(--ldp-connect-color,var(--tertiary,#47855f));} .ldp-connect-bar.is-goal{--ldp-connect-sheen-opacity:.24;--ldp-connect-sheen-duration:5.2s;} .ldp-connect-bar.is-goal .ldp-connect-bar-fill{overflow:hidden;} .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{content:"";position:absolute;inset:-1px auto -1px -45%;width:38%; border-radius:inherit;background:linear-gradient(90deg,transparent,rgba(255,255,255,var(--ldp-connect-sheen-opacity)),transparent); transform:skewX(-18deg);animation:ldp-connect-goal-sheen var(--ldp-connect-sheen-duration) cubic-bezier(.22,.61,.36,1) infinite;} .ldp-connect-bar.is-over{--ldp-connect-sheen-opacity:.3;--ldp-connect-sheen-duration:4.6s;} .ldp-connect-bar.is-over-high{--ldp-connect-sheen-opacity:.36;--ldp-connect-sheen-duration:4s;} .ldp-connect-bar.is-over-ultra{--ldp-connect-sheen-opacity:.42;--ldp-connect-sheen-duration:3.5s;} .ldp-connect-bar.is-over-epic{--ldp-connect-sheen-opacity:.48;--ldp-connect-sheen-duration:3s;} .ldp-connect-bar.is-over-high .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);} .ldp-connect-bar.is-over-ultra .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 20%,transparent),0 1px 4px color-mix(in srgb,var(--ldp-connect-color) 14%,transparent);} .ldp-connect-bar.is-over-epic .ldp-connect-bar-track{box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-connect-color) 26%,transparent),0 1px 5px color-mix(in srgb,var(--ldp-connect-color) 18%,transparent);} @keyframes ldp-connect-goal-sheen{ 0%,18%{left:-45%;opacity:0;} 24%{opacity:1;} 48%{left:112%;opacity:1;} 54%,100%{left:112%;opacity:0;}} .ldp-connect-quota-copy{display:flex;align-items:baseline;justify-content:space-between;gap:8px; color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px);font-weight:400;} .ldp-connect-error{display:flex;align-items:center;justify-content:space-between;gap:9px;padding:8px;border-radius:9px; background:var(--primary-very-low,#f7f7f7);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;} @media (max-width:480px){ .ldp-connect-rings{grid-template-columns:repeat(2,minmax(0,1fr));} .ldp-connect-detail-groups, .ldp-connect-bars,.ldp-connect-quotas{grid-template-columns:minmax(0,1fr);} .ldp-user-info-profile-body{grid-template-columns:56px minmax(0,1fr);padding-inline:12px;} .ldp-user-info-avatar{width:50px;height:50px;} } @media (prefers-reduced-motion:reduce){ .ldp-connect-bar.is-goal .ldp-connect-bar-fill::after{animation:none;display:none;}} .ldp-about-content{display:grid;gap:18px;padding:20px 0 4px;} .ldp-about-hero{display:flex;align-items:center;gap:12px;padding-bottom:18px; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-about-logo{display:block;width:50px;height:50px;flex:none;border-radius:50%;} .ldp-about-identity{display:grid;min-width:0;gap:3px;} .ldp-about-name{margin:0;color:var(--primary,#222);font-size:var(--ldp-font-xl,16px);font-weight:750;line-height:1.2;} .ldp-about-tagline{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.45;} .ldp-about-version{display:inline-flex;flex:none;align-self:flex-start;padding:4px 7px;border-radius:999px; background:var(--primary-very-low,#f2f4f6);color:var(--primary-high,#333); font-size:var(--ldp-font-sm,11px);font-weight:650;font-variant-numeric:tabular-nums;line-height:1;} .ldp-about-features{display:grid;} .ldp-about-feature{display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:start;padding:11px 0; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-about-feature-icon{display:inline-flex;width:32px;height:32px;align-items:center;justify-content:center; border-radius:9px;background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);} .ldp-about-feature-copy{display:grid;gap:2px;} .ldp-about-credits{display:grid;gap:4px;padding:12px;border:1px solid var(--primary-low,#ddd);border-radius:9px; background:var(--primary-very-low,#f7f7f7);} .ldp-about-credits a{color:var(--tertiary,#47855f);font-weight:650;text-decoration:none;} .ldp-about-credits a:hover{text-decoration:underline;} .ldp-settings-category-groups{display:grid;gap:14px;} .ldp-settings-category-list{display:grid;border-top:0;} .ldp-settings-category-list > .ldp-setting-row{padding:11px 0;border-bottom:0;} .ldp-settings-category-content{display:grid;gap:10px;padding:10px 0;} .ldp-color-groups{display:grid;gap:14px;} .ldp-settings-category-group,.ldp-color-group{display:grid;min-width:0;gap:0;padding:0 14px;border:1px solid var(--primary-low,#ddd); border-radius:10px;background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));} .ldp-color-group-head{display:grid;gap:2px;padding:12px 0 10px; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); transition:opacity .12s ease-out;} .ldp-color-group-title{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;} .ldp-color-group-description{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;} .ldp-settings-fields{display:grid;gap:0;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:16px; margin:0;padding:13px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);} .ldp-setting-row.ldp-setting-row-has-sharing{grid-template-columns:88px minmax(0,1fr) 30px;gap:12px;} .ldp-setting-label{font-weight:650;white-space:nowrap;color:var(--primary-high,#333);} .ldp-setting-group{min-width:0;padding:10px 0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-setting-group-title{padding:0 0 2px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);font-weight:700;line-height:1.35;} .ldp-setting-group > .ldp-setting-row{padding:9px 0 9px 12px;border-bottom:0;} .ldp-setting-group > .ldp-setting-row + .ldp-setting-row{border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);} .ldp-setting-group .ldp-setting-label{color:var(--primary-medium,#777);font-weight:600;} .ldp-font-scope-group{padding-top:0;} .ldp-settings-category-content > .ldp-settings-fields,.ldp-color-group > .ldp-settings-fields,.ldp-font-settings-fields{border-top:0;} .ldp-about-feature:last-child,.ldp-settings-category-content > .ldp-settings-fields > :last-child, .ldp-color-group > .ldp-settings-fields > :is(.ldp-setting-row,.ldp-setting-group):last-child, .ldp-settings-category-content .ldp-font-scope-group:not([hidden]){border-bottom:0;} .ldp-setting-help-tooltip{position:fixed;z-index:2147483646;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100vw - 24px)); pointer-events:none;opacity:0;transform:translateY(3px); transition:opacity .12s ease,transform .12s ease;} .ldp-setting-help-tooltip.is-visible{opacity:1;transform:translateY(0);} .ldp-settings-section:not([hidden]){display:grid;gap:18px;align-content:start;} .ldp-motion-choice-row{grid-template-columns:88px minmax(0,240px);justify-content:space-between;border-bottom:0;} .ldp-loading-animation-select{width:100%;} .ldp-loading-settings-preview{display:grid;gap:10px;padding-top:2px;} .ldp-loading-settings-preview-head{display:flex;align-items:center;justify-content:space-between;gap:12px;} .ldp-loading-settings-preview-head > span{display:flex;min-width:0;align-items:baseline;gap:8px;} .ldp-loading-settings-preview-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);} .ldp-loading-settings-preview-head small{overflow:hidden;color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;} .ldp-loading-preview-reroll{flex:none;border:1px solid var(--primary-low,#ddd);border-radius:7px;padding:6px 9px;background:var(--secondary,#fff); color:var(--primary-high,#333);font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;} .ldp-loading-preview-reroll:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 52%,var(--primary-low,#ddd));background:var(--primary-very-low,#f4f4f4);} .ldp-loading-preview-stage{display:grid;min-height:184px;place-items:center;overflow:hidden;} .ldp-loading-preview-stage .ldp-loading-visual{width:min(304px,100%);height:184px;max-width:100%;} .ldp-loading-preview-stage .ldp-loading-visual-inner{transform:scale(.84);} .ldp-loading-settings-note{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;} .ldp-performance-presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;} .ldp-performance-preset{min-width:0;border:1px solid var(--primary-low,#ddd);border-radius:7px; padding:8px 5px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} .ldp-performance-preset:hover{background:var(--primary-very-low,#f4f4f4);} .ldp-performance-fields .ldp-setting-row{grid-template-columns:minmax(0,1fr) 116px;gap:14px;padding:10px 0;} .ldp-performance-copy{min-width:0;display:grid;gap:2px;} .ldp-performance-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;} .ldp-performance-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.35;} .ldp-performance-control,.ldp-reader-window-input-wrap{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px;} .ldp-performance-control input{box-sizing:border-box;width:100%;height:34px;border:1px solid var(--primary-low,#ccc); border-radius:7px;background:var(--secondary,#fff);color:inherit;padding:5px 8px;text-align:right;font:inherit; font-variant-numeric:tabular-nums;} .ldp-performance-control em{min-width:24px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-style:normal;white-space:nowrap;} .ldp-performance-footer,.ldp-layout-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;} :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){display:inline-flex;align-items:center;gap:6px;flex:none;border:1px solid var(--primary-low,#ddd); border-radius:7px;padding:7px 9px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} :is(.ldp-performance-reset,.ldp-layout-reset):hover:not(:disabled){background:var(--primary-very-low,#f4f4f4);} .ldp-reader-window-reset:hover:not(:disabled){background:var(--secondary,#fff);} :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset):disabled{opacity:.38;cursor:default;} :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck){display:grid;grid-template-columns:auto minmax(0,1fr);gap:5px 10px;align-items:start; padding:10px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--primary-very-low,#f7f7f7);} .ldp-request-flow-bottleneck{gap:8px 10px;} :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) strong{padding:2px 7px;border-radius:999px;background:var(--success-low,#dceee2); color:var(--success,#47855f);font-size:var(--ldp-font-sm,11px);line-height:1.35;white-space:nowrap;} :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="warning"] strong{background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);} :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck)[data-level="danger"] strong{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);} :is(.ldp-resource-monitor-health,.ldp-request-flow-bottleneck) p{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);line-height:1.45;} .ldp-resource-monitor-updated{grid-column:2;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px); font-variant-numeric:tabular-nums;line-height:1.35;} .ldp-resource-monitor-table{--ldp-resource-monitor-columns:minmax(116px,1fr) minmax(108px,1.05fr) repeat(3,minmax(52px,.64fr)); min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); border-radius:10px;background:var(--secondary,#fff);} .ldp-resource-monitor-table-head,.ldp-resource-monitor-row{display:grid; grid-template-columns:var(--ldp-resource-monitor-columns);column-gap:8px;align-items:center;box-sizing:border-box;} .ldp-resource-monitor-table-head{min-height:34px;padding:8px 12px; border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent); background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff)); color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;line-height:1.2;text-align:right;} .ldp-resource-monitor-table-head span:first-child{text-align:left;} .ldp-resource-monitor-table-head span:nth-child(2){color:var(--primary-high,#333);font-weight:700;} .ldp-resource-monitor-row{min-height:54px;padding:10px 12px;} .ldp-resource-monitor-row + .ldp-resource-monitor-row{border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);} .ldp-resource-monitor-copy{display:grid;min-width:0;gap:2px;} .ldp-resource-monitor-copy strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:650;line-height:1.3;} .ldp-resource-monitor-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.3;} .ldp-resource-monitor-current{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);font-weight:750; font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;} .ldp-resource-monitor-summary{display:contents;} .ldp-resource-monitor-summary span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px); font-variant-numeric:tabular-nums;line-height:1.25;text-align:right;white-space:nowrap;} .ldp-resource-monitor-summary span:last-child{color:var(--primary-high,#333);font-weight:650;} .ldp-resource-monitor-trend-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;} .ldp-resource-monitor-trend-list{border-block:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-resource-monitor-trend-row{display:grid;grid-template-columns:92px 76px minmax(120px,1fr);gap:8px;align-items:center; min-height:48px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-resource-monitor-trend-row:first-child{border-top:0;} .ldp-resource-monitor-trend-row > strong{color:var(--primary-high,#333);font-size:var(--ldp-font-xs,10px); font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;} .ldp-resource-monitor-trend-row svg{display:block;width:100%;height:34px;overflow:visible;} .ldp-resource-monitor-trend-row polyline{fill:none;stroke:var(--tertiary,#47855f);stroke-width:2;stroke-linecap:round; stroke-linejoin:round;vector-effect:non-scaling-stroke;} .ldp-resource-monitor-trend-row[data-resource-monitor-chart="dom"] polyline{stroke:var(--ldp-request-metric-navy,#365673);} .ldp-resource-monitor-trend-row[data-resource-monitor-chart="retainedFloors"] polyline{stroke:var(--ldp-request-metric-bronze,#72551f);} .ldp-resource-monitor-evidence-head span,.ldp-resource-monitor-events-head span{color:var(--primary-medium,#777); font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;text-align:right;} .ldp-resource-monitor-scope-table{width:100%;max-width:100%;min-width:0;overflow:hidden;border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);} .ldp-resource-monitor-scope-head,.ldp-resource-monitor-scope-row{display:grid; grid-template-columns:minmax(110px,.7fr) minmax(120px,.8fr) repeat(2,minmax(0,1fr)); column-gap:8px;box-sizing:border-box;padding:8px 10px;} .ldp-resource-monitor-scope-head{background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff)); color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:650;align-items:center;} .ldp-resource-monitor-scope-row{min-height:68px;grid-template-rows:auto auto;row-gap:6px;align-items:start; border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);} .ldp-resource-monitor-scope-row > *{min-width:0;} .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;align-self:center;color:var(--primary-high,#333); font-size:var(--ldp-font-sm,11px);line-height:1.3;} .ldp-resource-monitor-scope-row span,.ldp-resource-monitor-scope-row small{color:var(--primary-medium,#777); font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;line-height:1.35;overflow-wrap:anywhere;} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:2 / -1;grid-row:2; padding-top:5px;border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 62%,transparent);} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]::before{content:"依据 · "; color:var(--primary-high,#333);font-weight:650;} .ldp-resource-monitor-event-log{max-height:280px;overflow:auto;border-block:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);scrollbar-gutter:stable;} .ldp-resource-monitor-event-row{display:grid;grid-template-columns:82px 34px 76px minmax(0,1fr) 104px;gap:8px; align-items:start;min-height:34px;box-sizing:border-box;padding:7px 0;border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);font-size:var(--ldp-font-xs,10px);line-height:1.35;} .ldp-resource-monitor-event-row:first-child{border-top:0;} .ldp-resource-monitor-event-row time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;white-space:nowrap;} .ldp-resource-monitor-event-state{font-weight:700;white-space:nowrap;} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-visible], .ldp-resource-monitor-event-state[data-visibility="visible"]{color:var(--tertiary,#47855f);} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-hidden], .ldp-resource-monitor-event-state[data-visibility="hidden"]{color:var(--ldp-request-metric-navy,#365673);} .ldp-resource-monitor-event-scope{color:var(--primary-high,#333);font-weight:650;} .ldp-resource-monitor-event-detail{min-width:0;color:var(--primary-high,#333);overflow-wrap:anywhere;} .ldp-resource-monitor-event-basis{color:var(--primary-medium,#777);overflow-wrap:anywhere;} .ldp-resource-monitor-event-empty{padding:14px 0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-resource-monitor-boundary{margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.5;} @media (max-width:700px){ .ldp-resource-monitor-table{border-radius:9px;} .ldp-resource-monitor-table-head{display:none;} .ldp-resource-monitor-row{grid-template-columns:minmax(0,1fr) auto;gap:7px 12px;min-height:0;padding:12px;} .ldp-resource-monitor-summary{grid-column:1 / -1;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0; margin-top:2px;padding-top:8px;border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);} .ldp-resource-monitor-summary span{padding-right:7px;text-align:left;} .ldp-resource-monitor-summary span + span{padding-left:8px;border-left:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 68%,transparent);} .ldp-resource-monitor-summary span::before{color:var(--primary-medium,#777);font-weight:400;} .ldp-resource-monitor-summary span:nth-child(1)::before{content:"均 ";} .ldp-resource-monitor-summary span:nth-child(2)::before{content:"峰 ";} .ldp-resource-monitor-summary span:nth-child(3)::before{content:"变 ";} .ldp-resource-monitor-trend-row{grid-template-columns:72px 64px minmax(84px,1fr);} .ldp-resource-monitor-scope-head{display:none;} .ldp-resource-monitor-scope-row{grid-template-columns:84px repeat(2,minmax(0,1fr));gap:6px 9px;padding:10px;} .ldp-resource-monitor-scope-row strong{grid-row:1 / 3;} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-current]{grid-column:2 / -1;} .ldp-resource-monitor-scope-row [data-resource-monitor-scope-basis]{grid-column:1 / -1;grid-row:auto;} .ldp-resource-monitor-event-row{grid-template-columns:76px 30px 64px minmax(0,1fr);} .ldp-resource-monitor-event-basis{grid-column:4;}} .ldp-request-flow-summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));overflow:hidden; border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);} .ldp-request-flow-metric{display:grid;min-width:0;min-height:52px;box-sizing:border-box;align-content:center;justify-items:center; gap:2px;padding:7px 8px;text-align:center;} .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);} .ldp-request-flow-metric span{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:550;line-height:1.25;} .ldp-request-flow-metric strong{color:var(--primary-high,#333);font-size:var(--ldp-font-sm,11px); font-weight:750;font-variant-numeric:tabular-nums;line-height:1.25;overflow-wrap:anywhere;white-space:normal;} .ldp-request-flow-metric:nth-child(1) strong{color:var(--ldp-request-metric-pine,#315f47);} .ldp-request-flow-metric:nth-child(2) strong{color:var(--ldp-request-metric-navy,#365673);} .ldp-request-flow-metric:nth-child(3) strong{color:var(--ldp-request-metric-plum,#5e4b72);} .ldp-request-flow-metric:nth-child(4) strong{color:var(--ldp-request-metric-bronze,#72551f);} .ldp-request-flow-metric:nth-child(5) strong{color:var(--ldp-request-metric-wine,#743f47);} .ldp-resource-monitor-trends,.ldp-resource-monitor-evidence,.ldp-resource-monitor-events,.ldp-request-flow-block{display:grid;gap:8px;} .ldp-resource-monitor-trend-head,.ldp-resource-monitor-evidence-head,.ldp-resource-monitor-events-head, .ldp-request-flow-block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;} .ldp-resource-monitor-trend-head strong,.ldp-resource-monitor-evidence-head strong, .ldp-resource-monitor-events-head strong,.ldp-request-flow-block-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);} .ldp-resource-monitor-trend-row > span,.ldp-request-flow-legend span, .ldp-request-flow-anomaly-detail small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);} .ldp-request-flow-block-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;} .ldp-request-flow-chart-block,.ldp-request-flow-types-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block{gap:0;overflow:hidden; border:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);border-radius:10px;background:var(--secondary,#fff);} .ldp-request-flow-chart-block .ldp-request-flow-block-head,.ldp-request-flow-anomaly-block .ldp-request-flow-block-head, .ldp-request-flow-log-block .ldp-request-flow-block-head{ min-height:34px;box-sizing:border-box;padding:8px 12px; border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent); background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));} .ldp-request-flow-trace{display:grid;gap:5px;margin:8px 12px 0;} .ldp-request-flow-trace-lane{display:grid;grid-template-columns:42px minmax(0,1fr);gap:7px;align-items:center;} .ldp-request-flow-trace-label{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px); font-weight:650;text-align:right;white-space:nowrap;} .ldp-request-flow-trace-track{position:relative;isolation:isolate;height:34px;overflow:hidden;border:1px solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);border-radius:5px; background:repeating-linear-gradient(to right,transparent 0,transparent calc(10% - 1px), color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) calc(10% - 1px), color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 48%,transparent) 10%), color-mix(in srgb,var(--primary-very-low,#f7f7f7) 56%,var(--secondary,#fff));} .ldp-request-flow-trace-queue,.ldp-request-flow-trace-wire{position:absolute;z-index:1;top:var(--ldp-request-flow-top,3px); left:var(--ldp-request-flow-left,0);width:max(2px,var(--ldp-request-flow-width,2px));height:4px;box-sizing:border-box;border-radius:999px;} .ldp-request-flow-trace-queue{height:3px;margin-top:1px;border:1px dashed var(--primary-medium,#777);opacity:.68;} .ldp-request-flow-trace-wire{background:var(--ldp-request-flow-color,var(--primary-medium,#777));box-shadow:0 0 0 1px color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 72%,transparent);} .ldp-request-flow-trace-wire[data-pending="true"]{background:color-mix(in srgb,var(--ldp-request-flow-color,var(--primary-medium,#777)) 62%,transparent);} .ldp-request-flow-trace-permit{position:absolute;z-index:2;top:calc(var(--ldp-request-flow-top,3px) - 1px); left:var(--ldp-request-flow-left,0);width:2px;height:7px;transform:translateX(-1px);border-radius:2px;background:var(--primary-high,#333);} .ldp-request-flow-trace-anomaly{position:absolute;z-index:3;top:calc(var(--ldp-request-flow-top,3px) - 2px); left:var(--ldp-request-flow-left,0);width:10px;height:10px;box-sizing:border-box;transform:translateX(-50%); border:2px solid var(--secondary,#fff);border-radius:50%;background:var(--highlight-high,#b38300); box-shadow:0 0 0 1px var(--highlight-high,#b38300);} .ldp-request-flow-trace-anomaly[data-level="danger"]{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);} .ldp-request-flow-axis{display:flex;justify-content:space-between;padding:4px 12px 0;color:var(--primary-medium,#777); font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;} .ldp-request-flow-axis span:nth-child(2){flex:1;text-align:center;} .ldp-request-flow-legend{display:flex;flex-wrap:wrap;gap:5px 10px;padding:7px 12px 10px;} .ldp-request-flow-legend span,.ldp-request-flow-type-name{display:inline-flex;align-items:center;gap:5px;min-width:0;} .ldp-request-flow-dot{width:7px;height:7px;flex:none;border-radius:50%;background:var(--ldp-request-flow-color,var(--primary-low,#ddd));} .ldp-request-flow-queue-key{width:11px;height:5px;box-sizing:border-box;border:1px dashed var(--primary-medium,#777);border-radius:999px;} .ldp-request-flow-warning-key,.ldp-request-flow-danger-key{width:8px;height:8px;box-sizing:border-box;border:2px solid var(--secondary,#fff); border-radius:50%;background:var(--highlight-high,#b38300);box-shadow:0 0 0 1px var(--highlight-high,#b38300);} .ldp-request-flow-danger-key{background:var(--danger,#c84545);box-shadow:0 0 0 1px var(--danger,#c84545);} [data-request-flow-type="topic"]{--ldp-request-flow-color:#47855f;} [data-request-flow-type="nested"]{--ldp-request-flow-color:#2870b8;} [data-request-flow-type="avatar"]{--ldp-request-flow-color:#7a5bb5;} [data-request-flow-type="media"]{--ldp-request-flow-color:#b66a26;} [data-request-flow-type="user"]{--ldp-request-flow-color:#2d7d78;} [data-request-flow-type="bookmark"]{--ldp-request-flow-color:#9a7600;} [data-request-flow-type="notification"]{--ldp-request-flow-color:#b94f4f;} [data-request-flow-type="realtime"]{--ldp-request-flow-color:#4f708f;} [data-request-flow-type="presence"]{--ldp-request-flow-color:#3d7f9d;} [data-request-flow-type="reaction"]{--ldp-request-flow-color:#a44f79;} [data-request-flow-type="search"]{--ldp-request-flow-color:#66717c;} [data-request-flow-type="read"]{--ldp-request-flow-color:#7a6c5d;} [data-request-flow-type="asset"]{--ldp-request-flow-color:#596979;} [data-request-flow-type="other"]{--ldp-request-flow-color:#8a8a8a;} .ldp-request-flow-anomalies{display:grid;max-height:174px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;} .ldp-request-flow-anomaly-row{display:grid;grid-template-columns:86px 68px minmax(0,1fr);gap:8px;align-items:center; min-height:42px;padding:6px 12px;border-left:3px solid var(--highlight-high,#b38300);border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent);font-size:var(--ldp-font-xs,10px);} .ldp-request-flow-anomaly-row[data-level="danger"]{border-left-color:var(--danger,#c84545);} .ldp-request-flow-anomaly-row:last-child{border-bottom:0;} .ldp-request-flow-anomaly-time{color:var(--primary-medium,#777);font-variant-numeric:tabular-nums;} .ldp-request-flow-anomaly-kind{display:inline-flex;justify-content:center;padding:2px 5px;border-radius:999px; background:var(--highlight-low,#fff8d8);color:var(--highlight-high,#806200);font-weight:700;white-space:nowrap;} .ldp-request-flow-anomaly-row[data-level="danger"] .ldp-request-flow-anomaly-kind{background:var(--danger-low,#f7dddd);color:var(--danger,#c84545);} .ldp-request-flow-anomaly-detail{display:grid;min-width:0;gap:2px;} .ldp-request-flow-anomaly-detail strong,.ldp-request-flow-anomaly-detail small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-request-flow-anomaly-detail strong{color:var(--primary-high,#333);font-size:var(--ldp-font-ui,12px);} .ldp-request-flow-type-head,.ldp-request-flow-type-row{display:grid;grid-template-columns:minmax(0,1fr) 62px 72px 44px;gap:8px;} .ldp-request-flow-type-head{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px; border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent); background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 76%,var(--secondary,#fff));} .ldp-request-flow-type-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-md,14px);} .ldp-request-flow-type-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px); text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;} .ldp-request-flow-types{display:grid;} .ldp-request-flow-type-row{min-height:34px;box-sizing:border-box;align-items:center;padding:4px 12px;font-size:var(--ldp-font-ui,12px);} .ldp-request-flow-type-row + .ldp-request-flow-type-row{border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 78%,transparent);} .ldp-request-flow-type-row > span:not(.ldp-request-flow-type-name){color:var(--primary-medium,#777);text-align:right;font-variant-numeric:tabular-nums;} .ldp-request-flow-type-name strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-ui,12px);} .ldp-request-flow-log{max-height:224px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;} .ldp-request-flow-log-row{display:grid;grid-template-columns:88px 44px 58px 54px 88px minmax(140px,1fr);gap:7px;align-items:center; min-height:34px;padding:5px 12px;border-bottom:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 65%,transparent); color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-variant-numeric:tabular-nums;} .ldp-request-flow-log-row[data-level="warning"]{box-shadow:inset 3px 0 var(--highlight-high,#b38300);} .ldp-request-flow-log-row[data-level="danger"]{box-shadow:inset 3px 0 var(--danger,#c84545);} .ldp-request-flow-log-row:last-child{border-bottom:0;} .ldp-request-flow-source{display:inline-flex;justify-content:center;padding:2px 4px;border-radius:4px; background:var(--primary-very-low,#f4f4f4);color:var(--primary-high,#333);} .ldp-request-flow-status{font-weight:700;text-align:right;} .ldp-request-flow-status.is-error{color:var(--danger,#c84545);} .ldp-request-flow-timing{overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap;} .ldp-request-flow-path{overflow:hidden;color:var(--primary-high,#333);text-overflow:ellipsis;white-space:nowrap;} .ldp-request-flow-empty,.ldp-font-family-empty{padding:18px 8px;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;} .ldp-request-flow-limit{display:grid;gap:7px;padding:11px;border:1px solid var(--primary-low,#ddd);border-radius:8px;} .ldp-request-flow-limit h4{margin:0;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);} .ldp-request-flow-limit strong{color:var(--primary-high,#333);} .ldp-request-flow-limit a{color:var(--d-link-color,#2870b8);text-decoration:none;} .ldp-request-flow-limit a:hover{text-decoration:underline;} @media (max-width:700px){ .ldp-request-flow-summary{grid-template-columns:repeat(2,minmax(0,1fr));} .ldp-request-flow-metric + .ldp-request-flow-metric{border-left:0;} .ldp-request-flow-metric:nth-child(even){border-left:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-request-flow-metric:nth-child(n+3){border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-request-flow-metric:last-child{grid-column:1 / -1;} .ldp-request-flow-type-head,.ldp-request-flow-type-row{grid-template-columns:minmax(0,1fr) 52px 64px 36px;gap:5px;} .ldp-request-flow-trace-lane{grid-template-columns:34px minmax(0,1fr);gap:5px;} .ldp-request-flow-axis span:nth-child(2){display:none;} .ldp-request-flow-anomaly-row{grid-template-columns:82px 62px minmax(0,1fr);padding-inline:8px;} .ldp-request-flow-log-row{grid-template-columns:86px 50px 50px minmax(100px,1fr);padding-inline:8px;} .ldp-request-flow-log-row > :nth-child(2),.ldp-request-flow-log-row > :nth-child(5){display:none;}} .ldp-about-feature-copy p,.ldp-about-credits p,.ldp-request-flow-limit p,.ldp-cache-note{ margin:0;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.5;} .ldp-config-actions{display:flex;flex-wrap:wrap;gap:7px;} .ldp-config-action{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:7px 10px; border:1px solid var(--primary-low,#ddd);border-radius:6px;background:var(--secondary,#fff);color:inherit; font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} .ldp-config-action:hover{background:var(--primary-very-low,#f4f4f4);} .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open):focus-visible, .ldp-only-op-toggle:focus-visible,.ldp-reaction-filter:focus-visible,.ldp-settings-close:focus-visible, .ldp-settings-theme-button:focus-visible,.ldp-user-info-title-refresh:focus-visible,.ldp-config-action:focus-visible{ outline:2px solid var(--tertiary,#47855f);outline-offset:1px;} .ldp-config-action.danger{color:var(--danger,#c44);} .ldp-cache-list{display:grid;gap:7px;} .ldp-cache-row{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:8px;align-items:center; padding:8px;border:1px solid var(--primary-low,#e2e2e2);border-radius:6px;font-size:var(--ldp-font-ui,12px);cursor:pointer;} .ldp-cache-row input{margin:0;} .ldp-cache-row strong,.ldp-cache-row small{display:block;} .ldp-cache-row small{margin-top:2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-cache-row em{font-style:normal;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:right;white-space:nowrap;} .ldp-cache-clear{justify-self:start;border:0;border-radius:6px;padding:8px 11px;background:var(--danger,#c44);color:#fff; font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} .ldp-cache-clear:disabled{cursor:default;opacity:.42;} .ldp-image-scale-control{display:grid;grid-template-columns:minmax(0,1fr) 52px auto;align-items:center;gap:10px;min-width:0;} .ldp-image-scale-control input{width:100%;min-width:150px;} .ldp-font-scale-control{display:flex;align-items:center;gap:10px;min-width:0;} .ldp-host-embed-size-control{display:grid;grid-template-columns:minmax(0,1fr) 46px auto;align-items:center;gap:10px;min-width:0;} .ldp-layout-ratio-control{display:grid;grid-template-columns:minmax(0,1fr) 52px;align-items:center;gap:10px;min-width:0;} .ldp-layout-ratio-control input{width:100%;min-width:150px;} .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr) minmax(190px,272px);align-items:center;gap:24px;} .ldp-history-edge-trigger-control{display:grid;grid-template-columns:minmax(0,1fr) 42px;align-items:center;gap:10px;min-width:0;width:100%;} @media (max-width:560px){ .ldp-setting-option-row.ldp-history-edge-trigger-row{grid-template-columns:minmax(0,1fr);align-items:start;gap:8px;} .ldp-history-edge-trigger-control{padding-left:0;} } .ldp-settings-popover input[type="range"]{ --ldp-range-progress:50%;appearance:none;-webkit-appearance:none;box-sizing:border-box; width:100%;min-width:0;height:24px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;} .ldp-settings-popover input[type="range"]::-webkit-slider-runnable-track{ height:5px;border-radius:999px;background:linear-gradient(90deg, var(--tertiary,#47855f) 0 var(--ldp-range-progress), color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff)) var(--ldp-range-progress) 100%);} .ldp-settings-popover input[type="range"]::-webkit-slider-thumb{ appearance:none;-webkit-appearance:none;box-sizing:border-box;width:15px;height:15px;margin-top:-5px;border:3px solid var(--secondary,#fff); border-radius:50%;background:var(--tertiary,#47855f); box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8)); transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);} .ldp-settings-popover input[type="range"]:hover::-webkit-slider-thumb{transform:scale(1.08);} .ldp-settings-popover input[type="range"]:active::-webkit-slider-thumb{transform:scale(.96);} .ldp-settings-popover input[type="range"]:focus-visible::-webkit-slider-thumb{ box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);} .ldp-settings-popover input[type="range"]::-moz-range-track{ height:5px;border:0;border-radius:999px;background:color-mix(in srgb,var(--primary-low,#d8d8d8) 72%,var(--secondary,#fff));} .ldp-settings-popover input[type="range"]::-moz-range-progress{ height:5px;border:0;border-radius:999px;background:var(--tertiary,#47855f);} .ldp-settings-popover input[type="range"]::-moz-range-thumb{ box-sizing:border-box;width:15px;height:15px;border:3px solid var(--secondary,#fff);border-radius:50%;background:var(--tertiary,#47855f); box-shadow:0 0 0 1px color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#d8d8d8)); transition:transform 160ms cubic-bezier(.22,1,.36,1),box-shadow 160ms cubic-bezier(.22,1,.36,1);} .ldp-settings-popover input[type="range"]:hover::-moz-range-thumb{transform:scale(1.08);} .ldp-settings-popover input[type="range"]:active::-moz-range-thumb{transform:scale(.96);} .ldp-settings-popover input[type="range"]:focus-visible::-moz-range-thumb{ box-shadow:0 0 0 1px var(--tertiary,#47855f),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);} .ldp-settings-popover input[type="range"]:disabled{opacity:.4;cursor:default;} .ldp-image-scale-value,.ldp-host-embed-size-value,.ldp-history-edge-trigger-value,.ldp-layout-ratio-value{ text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);} .ldp-layout-profile-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;} .ldp-layout-profile-tab{min-width:0;border:1px solid var(--primary-low,#ddd);border-radius:7px; padding:8px 6px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;cursor:pointer;} .ldp-layout-profile-tab:hover{background:var(--primary-very-low,#f4f4f4);} .ldp-performance-preset.active,.ldp-layout-profile-tab.active{border-color:color-mix(in srgb,var(--tertiary,#47855f) 45%,var(--primary-low,#ddd)); background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 46%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-font-scope-tabs{display:flex;gap:18px;padding:0 4px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-font-scope-tabs .ldp-layout-profile-tab{flex:0 0 auto;min-width:64px;padding:5px 4px 8px;border:0;border-radius:0; background:transparent;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-weight:600;} .ldp-font-scope-tabs .ldp-layout-profile-tab:hover{background:transparent;color:var(--primary-high,#333);} .ldp-font-scope-tabs .ldp-layout-profile-tab.active{border:0;background:transparent;color:var(--tertiary,#47855f); box-shadow:inset 0 -2px 0 var(--tertiary,#47855f);} .ldp-profile-field-sharing{position:relative;display:inline-flex;width:30px;height:28px;align-items:center;justify-content:center;cursor:pointer;} .ldp-profile-field-sharing input,.ldp-setting-switch input{position:absolute;inset:0;z-index:1;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;} .ldp-profile-field-sharing-button{box-sizing:border-box;display:inline-flex;width:30px;height:28px;align-items:center;justify-content:center; border:1px solid var(--primary-low,#ddd);border-radius:999px;background:transparent;color:var(--primary-medium,#777); transition:background-color 160ms cubic-bezier(.22,1,.36,1),border-color 160ms cubic-bezier(.22,1,.36,1),color 160ms cubic-bezier(.22,1,.36,1),transform 160ms cubic-bezier(.22,1,.36,1);} .ldp-profile-field-sharing input:hover + .ldp-profile-field-sharing-button{background:var(--primary-very-low,#f4f4f4);color:var(--primary-high,#333);} .ldp-profile-field-sharing input:checked + .ldp-profile-field-sharing-button{border-color:var(--tertiary,#47855f); background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 64%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-profile-field-sharing input:focus-visible + .ldp-profile-field-sharing-button, .ldp-setting-switch input:focus-visible + .ldp-setting-switch-track{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 48%,transparent);outline-offset:2px;} .ldp-profile-field-sharing input:active + .ldp-profile-field-sharing-button{transform:scale(.94);} .ldp-reader-profile-routing{margin:0 0 2px;color:var(--primary-medium,#777); font-size:var(--ldp-font-sm,11px);line-height:1.45;font-variant-numeric:tabular-nums;} .ldp-reader-profile-routing:is(.is-embedded,.is-shared){display:flex;align-items:center;flex-wrap:wrap;gap:5px 8px;} .ldp-reader-profile-line{display:flex;align-items:center;flex-wrap:wrap;gap:5px 8px;flex:0 0 100%;min-width:0;} .ldp-reader-profile-indicator{display:inline-flex;align-items:center;gap:4px;flex:none;padding:2px 7px;border-radius:999px; background:color-mix(in srgb,var(--tertiary-low,#dcefe3) 58%,var(--secondary,#fff));color:var(--tertiary,#47855f);white-space:nowrap;} .ldp-reader-profile-indicator strong{font-weight:700;} .ldp-reader-profile-metrics{min-width:0;} .ldp-reader-window-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;} .ldp-reader-window-field{display:grid;gap:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-reader-window-input{box-sizing:border-box;width:100%;height:34px;padding:5px 8px;border:1px solid var(--primary-low,#ccc); border-radius:7px;background:var(--secondary,#fff);color:inherit;font:inherit;text-align:right;font-variant-numeric:tabular-nums;} .ldp-reader-window-option{display:flex;align-items:center;gap:7px;font-size:var(--ldp-font-ui,12px);cursor:pointer;} .ldp-reader-window-option input{margin:0;} .ldp-setting-option-row{grid-template-columns:minmax(0,1fr) auto;cursor:pointer;} .ldp-setting-option-copy{display:grid;gap:3px;min-width:0;} .ldp-setting-option-copy strong,.ldp-reader-action-option-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.35;} .ldp-other-settings-fields{gap:14px;border-top:0;} .ldp-other-setting-group{min-width:0;padding:0 14px;border:1px solid var(--primary-low,#ddd);border-radius:10px; background:color-mix(in srgb,var(--primary-very-low,#f7f7f7) 34%,var(--secondary,#fff));} .ldp-settings-category-head,.ldp-other-setting-group-head{display:grid;gap:2px;padding:12px 0 10px; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-about-feature-copy strong,.ldp-about-credits strong,.ldp-settings-category-head strong, .ldp-other-setting-group-head strong{color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);line-height:1.35;} .ldp-settings-category-head small,.ldp-setting-option-copy small,.ldp-other-setting-group-head small,.ldp-reader-confirm-copy small{ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;} .ldp-other-setting-list{display:grid;} .ldp-other-setting-list > .ldp-setting-row{padding:11px 0;border-bottom:0;} .ldp-settings-category-list > .ldp-setting-row + .ldp-setting-row, .ldp-other-setting-list > .ldp-setting-row + .ldp-setting-row{ border-top:var(--ldp-divider-line-width,1px) solid color-mix(in srgb,var(--ldp-divider-line-color,#e5e5e5) 72%,transparent);} .ldp-other-setting-list > .ldp-nested-display-warning{margin:0 0 11px;} .ldp-settings-panel .ldp-history-sort-mode{min-height:38px;padding-block:6px;} .ldp-nested-display-warning{margin:-2px 2px 2px;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,var(--highlight,#ffb300) 12%,transparent);color:var(--primary-high,#444);font-size:var(--ldp-font-sm,11px);line-height:1.45;} .ldp-font-rendering-status[data-mode="external"]{color:var(--tertiary,#47855f);font-weight:650;} .ldp-setting-switch{position:relative;display:inline-flex;width:40px;height:22px;flex:none;} .ldp-setting-switch-track{position:relative;box-sizing:border-box;display:block;width:100%;height:100%; border:1px solid color-mix(in srgb,var(--primary,#222) 24%,transparent);border-radius:999px; background:color-mix(in srgb,var(--primary,#222) 14%,var(--secondary,#fff)); transition:background-color 180ms cubic-bezier(.22,1,.36,1),border-color 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);} .ldp-setting-switch-track::after{content:"";position:absolute;left:2px;top:50%;width:16px;height:16px; border-radius:50%;background:var(--secondary,#fff);box-shadow:0 1px 2px rgba(0,0,0,.24); transform:translateY(-50%);transition:transform 180ms cubic-bezier(.22,1,.36,1),background-color 180ms cubic-bezier(.22,1,.36,1);} .ldp-setting-switch input:hover + .ldp-setting-switch-track{ border-color:color-mix(in srgb,var(--primary,#222) 36%,transparent);} .ldp-setting-switch input:checked + .ldp-setting-switch-track{ border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);} .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{ background:#fff;transform:translate(20px,-50%);} .ldp-setting-switch input:active + .ldp-setting-switch-track{transform:scale(.97);} .ldp-setting-switch input:disabled{cursor:default;} .ldp-setting-switch input:disabled + .ldp-setting-switch-track{opacity:.45;} .ldp-boost-rule-row{grid-template-columns:110px minmax(0,1fr);gap:14px;padding:0;border:0;} .ldp-boost-rule-control{box-sizing:border-box;width:100%;height:34px;padding:5px 8px; border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff); color:inherit;font:inherit;} .ldp-boost-rule-preview{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} .ldp-boost-rule-preview code{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; color:var(--tertiary,#47855f);font-family:inherit;font-size:var(--ldp-font-ui,12px);} .ldp-reader-window-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;} .ldp-performance-status,.ldp-reader-window-status,.ldp-layout-total{ color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;} .ldp-layout-footer-actions{display:flex;align-items:center;gap:8px;flex:none;} .ldp-layout-total.warning{color:var(--danger,#c44);font-weight:650;} .ldp-layout-total.balanced{color:var(--success,#47855f);font-weight:650;} .ldp-layout-apply{display:inline-flex;align-items:center;gap:6px;border:0;border-radius:7px;padding:8px 11px; background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;} .ldp-layout-apply:disabled{opacity:.38;cursor:default;} .ldp-flash-range-control{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:10px;min-width:0;} .ldp-flash-value{text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);white-space:nowrap;} .ldp-color-control{display:flex;align-items:center;gap:8px;width:100%;min-width:0;} .ldp-color-control input[type="color"]{ -webkit-appearance:none;appearance:none;box-sizing:border-box;width:34px;height:34px;flex:0 0 34px;padding:0; overflow:hidden;border:2px solid var(--secondary,#fff);border-radius:50%;background:transparent; box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 24%,transparent);cursor:pointer; transition:box-shadow .15s ease-out,transform .15s ease-out;} .ldp-color-control input[type="color"]::-webkit-color-swatch-wrapper{padding:0;} .ldp-color-control input[type="color"]::-webkit-color-swatch{border:0;border-radius:50%;} .ldp-color-control input[type="color"]::-moz-color-swatch{border:0;border-radius:50%;} .ldp-color-control input[type="color"]:hover:not(:disabled){ box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px color-mix(in srgb,var(--tertiary,#47855f) 38%,transparent);transform:scale(1.04);} .ldp-color-control input[type="color"]:focus-visible{outline:0; box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);} .ldp-color-control input[type="color"]:active:not(:disabled){transform:scale(.94);} .ldp-color-control input[type="color"]:disabled{opacity:.42;cursor:default;} .ldp-flash-color-value, .ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value{ display:inline-flex;align-items:center;box-sizing:border-box;min-width:76px;min-height:28px;padding:0 8px;border-radius:7px; background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-high,#333); font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:var(--ldp-font-ui,12px);font-weight:650;letter-spacing:.02em;text-transform:uppercase;} .ldp-font-color-value{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC","Noto Sans CJK SC",sans-serif; letter-spacing:0;text-transform:none;} .ldp-color-reset{display:inline-flex;min-height:28px;margin-left:auto;align-items:center;gap:4px;padding:4px 7px;border:0; border-radius:7px;background:transparent;color:var(--primary-medium,#777);font:inherit;font-size:var(--ldp-font-sm,11px);cursor:pointer;white-space:nowrap;} .ldp-color-reset:hover:not(:disabled){background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);} .ldp-color-reset:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:1px;} .ldp-color-reset:disabled{opacity:.32;cursor:default;} .ldp-color-picker-popover{position:fixed;z-index:2147483610;box-sizing:border-box;width:min(268px,calc(100vw - 24px));padding:12px; border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);color:var(--primary,#222); box-shadow:0 12px 30px rgba(0,0,0,.2);pointer-events:auto;} .ldp-color-picker-title{margin:0 0 9px;font-size:var(--ldp-font-base,13px);font-weight:700;} .ldp-color-picker-presets{display:grid;grid-template-columns:repeat(6,28px);justify-content:space-between;gap:8px;margin-bottom:11px;} .ldp-color-picker-preset{box-sizing:border-box;width:28px;height:28px;padding:0;border:2px solid var(--secondary,#fff);border-radius:50%; background:var(--ldp-color-picker-preset);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 25%,transparent);cursor:pointer;} .ldp-color-picker-preset:hover,.ldp-color-picker-preset:focus-visible{outline:0;box-shadow:0 0 0 2px var(--secondary,#fff),0 0 0 4px var(--tertiary,#47855f);} .ldp-color-picker-preset[aria-pressed="true"]::after{content:"";display:block;width:8px;height:4px;margin:6px auto 0;border:solid currentColor;border-width:0 0 2px 2px;transform:rotate(-45deg);filter:drop-shadow(0 0 1px var(--secondary,#fff));} .ldp-color-picker-fields{display:flex;align-items:center;gap:8px;} .ldp-color-picker-hex{box-sizing:border-box;min-width:0;height:34px;flex:1;padding:5px 9px;border:1px solid var(--primary-low,#ccc);border-radius:7px; background:var(--secondary,#fff);color:inherit;font:650 var(--ldp-font-ui,12px)/1 ui-monospace,SFMono-Regular,Consolas,monospace;text-transform:uppercase;} .ldp-color-picker-hex:focus{border-color:var(--tertiary,#47855f);outline:0;box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-color-picker-hex[aria-invalid="true"]{border-color:var(--danger,#c44);box-shadow:0 0 0 1px var(--danger,#c44);} .ldp-color-picker-more{display:inline-flex;box-sizing:border-box;height:34px;flex:none;align-items:center;padding:5px 9px; border:1px solid var(--primary-low,#ccc);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;white-space:nowrap;} .ldp-color-picker-more:hover,.ldp-color-picker-more:focus-visible{border-color:var(--tertiary,#47855f);outline:0;background:color-mix(in srgb,var(--primary,#222) 5%,transparent);} .ldp-color-picker-advanced{display:grid;gap:9px;margin-top:11px;padding-top:11px;border-top:1px solid var(--primary-low,#ddd);} .ldp-color-picker-slider{display:grid;grid-template-columns:42px minmax(0,1fr) 38px;align-items:center;gap:7px; color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-color-picker-slider output{text-align:right;font-variant-numeric:tabular-nums;} .ldp-color-picker-slider input{width:100%;height:18px;margin:0;padding:0;border:0;outline:0;background:transparent;cursor:pointer;appearance:none;-webkit-appearance:none;} .ldp-color-picker-slider input::-webkit-slider-runnable-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);} .ldp-color-picker-slider input::-webkit-slider-thumb{width:16px;height:16px;margin-top:-5px;border:2px solid var(--secondary,#fff);border-radius:50%; background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);appearance:none;-webkit-appearance:none;} .ldp-color-picker-slider input::-moz-range-track{height:8px;border:1px solid color-mix(in srgb,var(--primary,#222) 18%,transparent);border-radius:999px;background:var(--ldp-color-slider);} .ldp-color-picker-slider input::-moz-range-thumb{width:14px;height:14px;border:2px solid var(--secondary,#fff);border-radius:50%; background:var(--ldp-color-slider-thumb,#fff);box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 42%,transparent);} .ldp-color-picker-slider input:focus-visible{filter:drop-shadow(0 0 2px var(--tertiary,#47855f));} .ldp-interface-font-scale-range,.ldp-font-scale-range,.ldp-composer-font-scale-range,.ldp-zebra-radius-range,.ldp-floor-preview-height-range,.ldp-flash-range{flex:1;min-width:120px;} .ldp-interface-font-scale-value,.ldp-font-scale-value,.ldp-composer-font-scale-value,.ldp-zebra-radius-value,.ldp-floor-preview-height-value{ width:46px;text-align:right;font-variant-numeric:tabular-nums;color:var(--primary-medium,#777);} .ldp-font-field-reset{min-width:104px;justify-content:flex-end;} .ldp-font-option-control{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0;} .ldp-font-family-picker{position:relative;min-width:0;flex:1;} .ldp-font-family-trigger{height:36px;padding:5px 9px 5px 10px;text-align:left;} .ldp-font-family-trigger[aria-expanded="true"]:not(:focus-visible){box-shadow:none;} .ldp-font-family-trigger .ldp-icon{transition:transform 150ms ease-out;} .ldp-font-family-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-font-family-menu{position:fixed;z-index:2147483608;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto auto; box-sizing:border-box;width:min(360px,calc(100vw - 24px));max-height:calc(100vh - 24px);overflow:hidden; padding:5px;border:1px solid var(--primary-low,#d7d7d7);border-radius:8px;background:var(--secondary,#fff); color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit); font-weight:var(--ldp-interface-font-weight,400);box-shadow:0 4px 8px rgba(0,0,0,.18);pointer-events:auto;} .ldp-font-family-search:focus-within{box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-font-family-search input{height:32px;} .ldp-font-family-source-status{margin:5px 2px 0;padding:5px 7px;border-radius:6px; background:var(--primary-very-low,#f3f3f3);color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);line-height:1.4;} .ldp-font-family-options{min-height:0;gap:7px;max-height:min(320px,42vh);margin-top:6px;} .ldp-font-family-options::-webkit-scrollbar,.ldp-topic-edit-category-options::-webkit-scrollbar{width:9px;} .ldp-font-family-options::-webkit-scrollbar-button,.ldp-topic-edit-category-options::-webkit-scrollbar-button{ display:none;width:0;height:0;} .ldp-font-family-options::-webkit-scrollbar-track,.ldp-topic-edit-category-options::-webkit-scrollbar-track{ border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px), color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px), transparent calc(50% + .75px) 100%);} .ldp-font-family-options::-webkit-scrollbar-thumb,.ldp-topic-edit-category-options::-webkit-scrollbar-thumb{ border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));} .ldp-font-family-group{display:grid;gap:2px;} .ldp-font-family-group-title{padding:4px 7px 2px;color:var(--primary-medium,#777); font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1.25;} .ldp-font-family-option{grid-template-columns:minmax(0,1fr) auto;color:inherit;} .ldp-font-family-option-copy strong{font-weight:650;} .ldp-font-family-preview{min-width:44px;color:var(--primary-high,#333);font-size:var(--ldp-font-base,13px);text-align:right;white-space:nowrap;} .ldp-font-family-manual{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px;padding:6px 7px 1px; border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-font-family-manual button{padding:5px 7px;border:0;border-radius:6px;background:transparent;color:var(--tertiary,#47855f); font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;cursor:pointer;} .ldp-font-family-manual button:hover,.ldp-font-family-manual button:focus-visible{background:var(--primary-very-low,#f2f2f2);outline:0;} .ldp-font-weight-select{width:132px;flex:none;} .ldp-font-family-custom{width:100%;min-width:0;flex:1 0 100%;order:3;} .ldp-font-weight-select,.ldp-font-family-custom{ box-sizing:border-box;border:1px solid var(--primary-low,#ccc); border-radius:8px;background:var(--secondary,#fff);color:inherit;height:36px;padding:5px 10px;font:inherit;} .ldp-settings-panel,.ldp-setting-help-tooltip{ --ldp-font-micro:8px;--ldp-font-xs:9px;--ldp-font-sm:10px;--ldp-font-ui:11px;--ldp-font-base:12px; --ldp-font-md:13px;--ldp-font-lg:14px;--ldp-font-xl:15px;--ldp-font-2xl:16px;--ldp-font-3xl:17px;} .ldp-settings-panel .ldp-settings-section:not([hidden]){gap:16px;} .ldp-settings-panel .ldp-color-groups{gap:14px;} .ldp-settings-panel .ldp-setting-row{padding-block:11px;} .ldp-settings-panel .ldp-setting-group{padding:8px 0 3px;} .ldp-settings-panel .ldp-setting-group > .ldp-setting-row{padding:7px 0 7px 12px;} .ldp-settings-panel .ldp-performance-fields .ldp-setting-row{padding-block:11px;} .ldp-settings-panel :is(.ldp-performance-control input,.ldp-reader-window-input,.ldp-boost-rule-control){height:32px;padding-block:4px;} .ldp-settings-panel :is(.ldp-font-family-trigger,.ldp-font-weight-select,.ldp-font-family-custom){height:34px;padding-block:4px;} .ldp-settings-panel .ldp-font-family-search{height:32px;} .ldp-settings-panel .ldp-font-family-search input{height:30px;} .ldp-settings-panel .ldp-font-family-option{min-height:34px;} .ldp-settings-panel :is(.ldp-performance-preset,.ldp-layout-profile-tab){padding-block:6px;} .ldp-settings-panel .ldp-font-scope-tabs .ldp-layout-profile-tab{padding-block:4px 7px;} .ldp-settings-panel :is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset){padding:6px 8px;} .ldp-settings-panel .ldp-layout-apply{padding:7px 10px;} .ldp-settings-panel .ldp-config-action{min-height:32px;padding:6px 9px;} .ldp-settings-panel .ldp-cache-row{padding:7px;} .ldp-settings-panel .ldp-cache-clear{padding:7px 10px;} .ldp-settings-panel .ldp-boost-rule-row{padding:11px 0;} .ldp-settings-panel .ldp-setting-switch{width:36px;height:20px;} .ldp-settings-panel .ldp-setting-switch-track::after{width:14px;height:14px;} .ldp-settings-panel .ldp-setting-switch input:checked + .ldp-setting-switch-track::after{transform:translate(18px,-50%);} .ldp-settings-panel .ldp-color-control input[type="color"]{width:32px;height:32px;flex-basis:32px;} .ldp-settings-panel :is(.ldp-flash-color-value,.ldp-accent-color-value,.ldp-link-color-value,.ldp-zebra-color-value,.ldp-list-zebra-color-value,.ldp-reply-line-color-value,.ldp-quote-line-color-value,.ldp-divider-line-color-value,.ldp-floor-preview-color-value,.ldp-color-reset){min-height:26px;} .ldp-settings-panel .ldp-resource-monitor-table-head{min-height:32px;padding-block:6px;} .ldp-settings-panel .ldp-resource-monitor-row{min-height:50px;padding-block:8px;} .ldp-settings-panel .ldp-resource-monitor-trend-row{min-height:44px;} .ldp-settings-panel .ldp-request-flow-metric{min-height:48px;padding-block:6px;} .ldp-settings-panel :is(.ldp-request-flow-chart-block,.ldp-request-flow-anomaly-block,.ldp-request-flow-log-block) .ldp-request-flow-block-head{min-height:32px;padding-block:6px;} .ldp-settings-panel :is(.ldp-request-flow-type-head,.ldp-request-flow-type-row,.ldp-request-flow-log-row){min-height:32px;} .ldp-reader-refresh,.ldp-close{cursor:pointer;border:none;background:transparent;color:inherit;padding:4px;} .ldp-reader-refresh:disabled{cursor:wait;opacity:.45;} .ldp-reader-refresh.is-refreshing .ldp-icon{animation:ldp-spin .9s linear infinite;} @media (prefers-reduced-motion:reduce){.ldp-reader-refresh.is-refreshing .ldp-icon{animation:none;}} .ldp-icon-btn{width:28px;height:28px;border-radius:6px;display:inline-flex; align-items:center;justify-content:center;} .ldp-icon-btn:hover{background:var(--primary-very-low,#f0f0f0);} .ldp-icon{width:14px;height:14px;stroke:currentColor;stroke-width:2; stroke-linecap:round;stroke-linejoin:round;fill:none;flex:none;} .ldp-reader-main{position:relative;display:grid;flex:1;min-height:0;grid-template-columns: minmax(0,var(--ldp-layout-left,10%)) minmax(0,var(--ldp-layout-main,72%)) minmax(0,var(--ldp-layout-gap,3%)) minmax(0,var(--ldp-layout-timeline,10%)) minmax(0,var(--ldp-layout-right,5%));} .ldp-fullpage .ldp-reader-main{grid-template-columns: minmax(0,var(--ldp-layout-left,20%)) minmax(0,var(--ldp-layout-main,58%)) minmax(0,var(--ldp-layout-gap,5%)) minmax(0,var(--ldp-layout-timeline,9%)) minmax(0,var(--ldp-layout-right,8%));} .ldp-rate-limit-notice{position:absolute;z-index:9;top:10px;left:50%;transform:translateX(-50%); display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px; width:min(680px,calc(100% - 24px));box-sizing:border-box;padding:9px 10px; border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 54%,var(--primary-low,#ddd));border-radius:9px; background:color-mix(in srgb,var(--highlight-low,#fff2c2) 82%,var(--secondary,#fff));color:var(--primary,#222); box-shadow:0 6px 20px rgba(0,0,0,.16);font-size:var(--ldp-font-ui,12px);line-height:1.4;pointer-events:auto;} .ldp-rate-limit-icon{display:inline-flex;width:26px;height:26px;align-items:center;justify-content:center; border-radius:7px;background:color-mix(in srgb,var(--highlight,#e7a400) 18%,transparent);color:var(--highlight,#b77900);} .ldp-rate-limit-copy{display:grid;gap:1px;min-width:0;} .ldp-rate-limit-copy strong{font-size:var(--ldp-font-base,13px);line-height:1.3;} .ldp-rate-limit-detail{color:var(--primary-high,#4d5963);overflow-wrap:anywhere;} .ldp-rate-limit-challenge{display:inline-flex;min-height:30px;align-items:center;justify-content:center;gap:5px; padding:5px 9px;border:1px solid color-mix(in srgb,var(--highlight,#e7a400) 46%,var(--primary-low,#ddd));border-radius:7px; background:var(--secondary,#fff);color:inherit;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650; white-space:nowrap;text-decoration:none;cursor:pointer;} .ldp-rate-limit-challenge:hover{background:color-mix(in srgb,var(--highlight-low,#fff2c2) 54%,var(--secondary,#fff));} .ldp-reader-lock-button .ldp-icon,.ldp-reader-pin-button .ldp-icon,.ldp-reader-placement-option .ldp-icon, .ldp-notification-mark-all .ldp-icon,:is(.ldp-performance-reset,.ldp-reader-window-reset,.ldp-layout-reset) .ldp-icon, .ldp-layout-apply .ldp-icon,.ldp-rate-limit-challenge .ldp-icon{width:13px;height:13px;} @media (max-width:600px){ .ldp-rate-limit-notice{grid-template-columns:auto minmax(0,1fr);align-items:start;} .ldp-rate-limit-challenge{grid-column:2;justify-self:start;} } .ldp-reader-history-nav{position:absolute;top:50%;z-index:8;width:34px;height:34px; transform:translateY(-50%); display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;padding:0; border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff); color:var(--primary-medium,#777);cursor:pointer;pointer-events:none;opacity:0; transition:opacity 160ms ease-out,transform 160ms ease-out;} .ldp-fullpage .ldp-reader-history-nav{position:fixed;} .ldp-reader-history-edge{display:contents;} .ldp-reader-history-back{left:25px;} .ldp-reader-history-forward{left:auto;right:25px;} .ldp-reader-history-edge.is-active .ldp-reader-history-nav, .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav, .ldp-reader-history-nav:focus-visible{pointer-events:auto;opacity:.35;} .ldp-reader-history-nav:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);opacity:1;} .ldp-reader-history-nav:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;opacity:1;} .ldp-reader-history-nav:disabled{cursor:wait;} .ldp-reader-history-edge.is-active .ldp-reader-history-nav:disabled, .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav:disabled{opacity:.45;} .ldp-body{grid-column:1 / -1;grid-row:1;display:grid;grid-template-columns:subgrid;min-height:0;position:relative; grid-auto-rows:max-content;align-content:start;box-sizing:border-box;width:100%;max-width:none; margin:0;padding:0;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:auto;} .ldp-body:is(.ldp-stream-viewport-anchor,.ldp-floor-preview-positioning,.ldp-quote-positioning){overflow-anchor:none;} .ldp-body > *{grid-column:2;box-sizing:border-box;width:100%; margin-left:0;margin-right:0;padding-left:20px;padding-right:20px;} .ldp-body > .ldp-sentinel{padding-left:0;padding-right:0;} .ldp-topic-timeline{position:relative;grid-column:4;grid-row:1;left:auto;right:auto;top:auto;bottom:auto;z-index:4; box-sizing:border-box;width:100%;max-width:100%;margin:24px 0;padding:0 4px;align-self:stretch;justify-self:stretch; display:grid;grid-template-rows:auto minmax(150px,1fr) auto;gap:10px; color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);line-height:1.25;pointer-events:none;} .ldp-topic-timeline-date,.ldp-topic-timeline-relative{min-width:0;padding:0 0 0 2px; overflow:hidden;border:0;background:transparent;color:inherit;font:inherit;cursor:pointer;pointer-events:auto; text-align:left;text-overflow:ellipsis;white-space:pre-line;} .ldp-topic-timeline-date:hover:not(:disabled),.ldp-topic-timeline-relative:hover:not(:disabled){color:var(--primary,#222);text-decoration:underline;} .ldp-topic-timeline-date:focus-visible,.ldp-topic-timeline-relative:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:2px;border-radius:3px;} .ldp-topic-timeline-date:disabled,.ldp-topic-timeline-relative:disabled{cursor:default;} .ldp-topic-timeline-footer{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;} .ldp-topic-timeline-relative{flex:0 0 auto;overflow:visible;white-space:nowrap;} .ldp-topic-timeline-top{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center; flex:none;box-sizing:border-box;padding:0;border:1px solid var(--primary-low,#ccc);border-radius:50%; background:var(--secondary,#fff);color:inherit;cursor:pointer;pointer-events:auto;} .ldp-topic-timeline-top:hover{border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);color:var(--primary,#222);} .ldp-topic-timeline-track{position:relative;width:100%;min-height:150px;padding:0;border:0; background:transparent;color:inherit;cursor:ns-resize;pointer-events:auto;touch-action:none;} .ldp-topic-timeline-track::before{content:"";position:absolute;left:15px;top:10px;bottom:10px;width:0; border-left:var(--ldp-reply-line-width,1px) solid var(--ldp-reply-line-color,#47855f);} .ldp-topic-timeline-cursor{position:absolute;z-index:2;left:30px; top:var(--ldp-timeline-lens-top,calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px)));width:max-content;min-width:44px;height:82px; transform:translate3d(0,-50%,0) scale(.94);transform-origin:left center;will-change:top,transform,opacity;contain:layout style; color:var(--primary-medium,#777);font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400); font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0; transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);} .ldp-topic-timeline-cursor > span{position:absolute;left:0;top:50%;display:block;min-width:0;line-height:1;transform-origin:left center; transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 16px),0) scale(var(--ldp-lens-scale,1)); backface-visibility:hidden;will-change:transform,opacity;font-size:var(--ldp-font-md,14px);font-weight:inherit;opacity:var(--ldp-lens-opacity,0);} .ldp-topic-timeline-cursor > span::before{content:"";position:absolute;left:-11px;top:50%;width:7px;height:2px; transform:translateY(-50%) scaleX(.35);transform-origin:right center;border-radius:999px;background:var(--tertiary,#47855f); opacity:0;transition:opacity .08s ease-out,transform .08s ease-out;} .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected{font-weight:inherit;} .ldp-topic-timeline-cursor > .ldp-timeline-lens-selected::before{transform:translateY(-50%) scaleX(1);opacity:.92;} .ldp-topic-timeline-track.ldp-timeline-hovering .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1);} .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor{opacity:1;transform:translate3d(0,-50%,0) scale(1); transition:opacity .1s ease-out,transform .1s cubic-bezier(.22,1,.36,1);} .ldp-topic-timeline-track.ldp-timeline-jumping .ldp-topic-timeline-cursor > .ldp-timeline-lens-focus{animation:ldp-topic-timeline-lens-focus .46s ease-out both;} @keyframes ldp-topic-timeline-lens-focus{ 0%{filter:blur(.35px);opacity:.52;} 58%{filter:blur(0);opacity:1;} 100%{filter:none;opacity:1;} } .ldp-topic-timeline-thumb{position:absolute;left:11px;top:calc(10px + var(--ldp-timeline-progress,0) * (100% - 40px)); width:9px;height:20px;border-radius:5px; background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));} .ldp-topic-timeline-count{position:absolute;left:30px;top:calc(20px + var(--ldp-timeline-progress,0) * (100% - 40px)); transform:translateY(-50%);display:inline-flex;align-items:center;gap:1px;max-width:none; overflow:visible;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400); font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;transition:opacity .12s ease-out;} .ldp-topic-timeline-preview{position:absolute;z-index:1;left:30px; top:calc(20px + var(--ldp-timeline-preview-progress,0) * (100% - 40px)); transform:translate(var(--ldp-timeline-preview-shift-x,0px),calc(-50% - 14px));display:inline-flex;align-items:center;gap:1px;max-width:calc(100% - 30px); overflow:hidden;text-overflow:ellipsis;color:var(--primary,#222);font-size:clamp(var(--ldp-font-xs,10px),.8vw,var(--ldp-font-sm,11px));font-weight:var(--ldp-reader-font-weight-base,400); font-variant-numeric:tabular-nums;text-align:left;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s ease-out;} .ldp-topic-timeline-track.ldp-timeline-previewing .ldp-topic-timeline-preview{opacity:.45;} .ldp-topic-timeline-track:is(.ldp-timeline-hovering,.ldp-timeline-jumping) :is(.ldp-topic-timeline-count,.ldp-topic-timeline-preview){opacity:0;} .ldp-topic-timeline-track:focus-visible{outline:2px solid var(--tertiary,#47855f);outline-offset:3px;border-radius:4px;} .ldp-live-update{position:absolute;left:50%;bottom:18px;z-index:6;transform:translateX(-50%);opacity:.65; display:inline-flex;align-items:center;border:0;border-radius:999px;padding:8px 13px; background:var(--tertiary,#47855f);color:#fff;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700; box-shadow:0 4px 8px rgba(0,0,0,.2);white-space:nowrap;} .ldp-live-update-jump{display:inline-flex;align-items:center;gap:6px;min-width:0;border:0;padding:0; background:transparent;color:inherit;font:inherit;cursor:pointer;white-space:nowrap;} .ldp-live-update-jump:hover{filter:brightness(1.08);} .ldp-live-update-dismiss{position:absolute;top:-8px;right:-8px;width:20px;height:20px;display:inline-flex; align-items:center;justify-content:center;box-sizing:border-box;border:0;border-radius:50%;padding:0; background:var(--tertiary,#47855f);color:#fff;cursor:pointer;box-shadow:0 2px 5px rgba(0,0,0,.22);} .ldp-live-update-dismiss:hover{background:rgba(255,255,255,.16);color:#fff;} .ldp-live-update-dismiss .ldp-icon{width:11px;height:11px;} .ldp-live-update-jump:focus-visible,.ldp-live-update-dismiss:focus-visible{outline:2px solid var(--secondary,#fff);outline-offset:2px;} /* 阅读器沉浸式载入场景 */ .ldp-body > .ldp-loadmask{position:absolute;inset:0;z-index:7;grid-column:1 / -1;width:100%;height:100%;margin:0; display:grid;place-items:center;box-sizing:border-box;padding:0;overflow:hidden; background:radial-gradient(ellipse at 50% 30%,color-mix(in srgb,var(--tertiary,#47855f) 8%,transparent),transparent min(46vw,330px)),var(--secondary,#fff);color:inherit;} .ldp-loading-stage{position:absolute;inset:0;z-index:1;display:grid;grid-template-rows:60% 40%;place-items:center;width:100%;height:100%;box-sizing:border-box;text-align:center;} .ldp-loading-stage > .ldp-loading-visual{grid-area:1 / 1;} .ldp-loading-copy{position:absolute;top:calc(30% + 120px);left:50%;display:flex;width:min(520px,calc(100% - 48px)); flex-direction:column;align-items:center;transform:translateX(-50%);} .ldp-loading-visual{--ldp-loader-accent:var(--tertiary,#47855f);--ldp-loader-brand:#f5b72e;position:relative;display:grid;place-items:center; width:min(360px,82vw);height:230px;overflow:visible;background:transparent;isolation:isolate;pointer-events:none;user-select:none;} .ldp-loading-visual-inner{position:relative;width:280px;height:170px;color:var(--ldp-loader-accent);transform:scale(1.08);transform-origin:center;} .ldp-loading-visual-inner span{position:absolute;display:block;box-sizing:border-box;will-change:transform,opacity;} .ldp-loading-mode{margin-top:2px;color:var(--tertiary,#47855f);font-size:10px;font-weight:750;letter-spacing:.18em;text-transform:uppercase;opacity:.7;} .ldp-loading-status{display:flex;align-items:center;justify-content:center;gap:9px;min-height:30px;margin-top:10px;color:var(--primary,#222);font-size:clamp(17px,2.2vw,21px);font-weight:720;letter-spacing:.02em;} .ldp-loading-status strong{display:inline-flex;align-items:center;min-height:22px;padding:0 8px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 22%,transparent);border-radius:999px; background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));color:var(--tertiary,#47855f);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;} .ldp-loading-status strong:empty{display:none;} .ldp-loading-detail{min-height:20px;margin-top:5px;color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);line-height:1.55;} /* LINUX DO Reader 十个真实等待场景:统一黑白黄识别,动作分别对应阅读器能力。 */ .ldp-loading-visual[data-animation="portal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 88%,var(--secondary,#fff));} .ldp-loader-portal::before{content:"LINUX DO READER";position:absolute;left:84px;top:68px;width:112px;height:32px;display:grid;place-items:center;z-index:1;white-space:nowrap; color:var(--ldp-loader-accent);font:820 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.13em;text-align:center; border-top:3px solid var(--ldp-loader-brand);border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 30%,transparent); animation:ldp-loader-topic-mark 3.8s ease-in-out infinite;} .ldp-loader-portal::after{content:"";position:absolute;left:139px;top:25px;width:2px;height:121px;opacity:.55; background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 8% 34%,transparent 34% 66%,var(--ldp-loader-brand) 66% 92%,transparent);animation:ldp-loader-topic-spine 3.8s ease-in-out infinite;} .ldp-loader-portal span{--side:-1;left:22px;top:calc(25px + var(--i) * 23px);width:91px;height:18px;transform-origin:right center; border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 27%,transparent); animation:ldp-loader-topic-open 3.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.16s);} .ldp-loader-portal span:nth-child(even){--side:1;left:167px;transform-origin:left center;} .ldp-loader-portal span::before{content:attr(data-copy);position:absolute;inset:auto 0 4px;color:color-mix(in srgb,var(--primary,#222) 56%,transparent); font:750 8px/1 ui-monospace,monospace;letter-spacing:.12em;text-align:right;} .ldp-loader-portal span:nth-child(even)::before{text-align:left;} .ldp-loader-portal span:nth-child(n+3)::after{content:"";position:absolute;right:0;bottom:5px;width:calc(38px + var(--i) * 5px);height:1px;background:color-mix(in srgb,var(--primary,#222) 18%,transparent);} .ldp-loader-portal span:nth-child(even)::after{left:0;right:auto;} @keyframes ldp-loader-topic-mark{0%,100%{transform:scale(.94);opacity:.55}45%,70%{transform:scale(1);opacity:1}} @keyframes ldp-loader-topic-spine{0%,12%{transform:scaleY(.08);opacity:0}38%,78%{transform:scaleY(1);opacity:1}100%{transform:scaleY(.32);opacity:0}} @keyframes ldp-loader-topic-open{0%,12%{transform:translate3d(calc(var(--side) * 24px),0,0) scaleX(.12);opacity:0}40%,72%{transform:translate3d(0,0,0) scaleX(1);opacity:.94}100%{transform:translate3d(calc(var(--side) * -8px),0,0) scaleX(.72);opacity:0}} .ldp-loading-visual[data-animation="constellation"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 78%,var(--tertiary,#47855f));} .ldp-loader-thread-index::before{content:"REPLY / THREAD";position:absolute;left:32px;top:16px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;} .ldp-loader-thread-index::after{content:"";position:absolute;left:27px;top:38px;width:2px;height:108px;transform-origin:center top;border-radius:99px; background:linear-gradient(180deg,transparent,var(--ldp-loader-brand) 12% 70%,transparent);box-shadow:0 0 10px color-mix(in srgb,var(--ldp-loader-brand) 20%,transparent); animation:ldp-loader-thread-caret 5.2s cubic-bezier(.65,0,.35,1) infinite;} .ldp-loader-thread-index span{left:calc(38px + var(--depth) * 26px);top:calc(38px + var(--i) * 21px);width:calc(207px - var(--depth) * 25px);height:17px; border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 17%,transparent);color:color-mix(in srgb,var(--primary,#222) 57%,var(--ldp-loader-accent)); animation:ldp-loader-thread-row 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .08s);} .ldp-loader-thread-index span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.055em;} .ldp-loader-thread-index span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:720 8px/1 ui-monospace,monospace;letter-spacing:.07em;} .ldp-loader-thread-index span:first-child{border-color:color-mix(in srgb,var(--ldp-loader-brand) 72%,transparent);color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));} .ldp-loader-thread-index span:first-child::before{padding:2px 6px;background:var(--ldp-loader-brand);color:#202124;clip-path:polygon(3% 12%,97% 0,100% 87%,0 100%);} @keyframes ldp-loader-thread-caret{0%,9%{transform:scaleY(.24);opacity:.18}24%{opacity:.92}64%{transform:scaleY(1);opacity:.86}76%,100%{transform:scaleY(.46);opacity:.18}} @keyframes ldp-loader-thread-row{0%,8%{transform:translate3d(-9px,0,0);opacity:.24}24%,72%{transform:translate3d(0,0,0);opacity:.96}88%,100%{transform:translate3d(4px,0,0);opacity:.38}} .ldp-loading-visual[data-animation="corridor"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 64%,var(--tertiary,#47855f));} .ldp-loader-floor-reel{background:linear-gradient(90deg,transparent 0 65px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 66px,transparent 67px);} .ldp-loader-floor-reel::before{content:"FLOOR / TIME";position:absolute;left:28px;top:16px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;} .ldp-loader-floor-reel::after{content:"";position:absolute;left:57px;top:40px;width:172px;height:2px;transform-origin:left center; background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 36%,transparent) 58%,transparent); box-shadow:0 0 9px color-mix(in srgb,var(--ldp-loader-brand) 18%,transparent);animation:ldp-loader-reel-window 4.9s cubic-bezier(.65,0,.35,1) infinite;} .ldp-loader-floor-reel span{left:68px;top:calc(33px + var(--i) * 25px);width:162px;height:22px;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 15%,transparent); color:color-mix(in srgb,var(--primary,#222) 52%,var(--ldp-loader-accent));animation:ldp-loader-reel-row 4.9s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .06s);} .ldp-loader-floor-reel span::before{content:attr(data-floor);position:absolute;left:9px;bottom:4px;font:740 11px/1 ui-monospace,monospace;letter-spacing:.09em;} .ldp-loader-floor-reel span::after{content:attr(data-time);position:absolute;right:5px;bottom:4px;font:620 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.11em;} .ldp-loader-floor-reel span:nth-child(3){color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:color-mix(in srgb,var(--ldp-loader-brand) 64%,transparent);} .ldp-loader-floor-reel span:nth-child(3)::before{font-size:15px;} @keyframes ldp-loader-reel-window{0%,8%{transform:translate3d(0,0,0) scaleX(.18);opacity:.16}22%{opacity:.95}66%{transform:translate3d(0,100px,0) scaleX(1);opacity:.86}78%,100%{transform:translate3d(0,100px,0) scaleX(.48);opacity:.16}} @keyframes ldp-loader-reel-row{0%,8%{transform:translate3d(0,5px,0);opacity:.34}24%,74%{transform:translate3d(0,0,0);opacity:.92}88%,100%{transform:translate3d(0,-3px,0);opacity:.42}} .ldp-loading-visual[data-animation="typewave"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 70%,var(--tertiary,#47855f));} .ldp-loader-typewave{overflow:hidden;background:linear-gradient(90deg,transparent 49.7%,color-mix(in srgb,var(--ldp-loader-accent) 10%,transparent) 49.8% 50.2%,transparent 50.3%);} .ldp-loader-typewave::before{content:"MARKDOWN → RENDERED";position:absolute;left:34px;top:20px;color:color-mix(in srgb,var(--primary,#222) 44%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.13em;} .ldp-loader-typewave::after{content:"";position:absolute;left:72px;top:38px;width:2px;height:103px;border-radius:99px;background:linear-gradient(180deg,transparent,var(--ldp-loader-brand),transparent); box-shadow:0 0 12px color-mix(in srgb,var(--ldp-loader-brand) 34%,transparent);animation:ldp-loader-markdown-scan 4.8s cubic-bezier(.65,0,.35,1) infinite;} .ldp-loader-typewave span{height:25px;box-sizing:border-box;will-change:transform,opacity;} .ldp-loader-typewave span:nth-child(-n+3){left:31px;top:calc(49px + var(--row) * 34px);width:96px;color:color-mix(in srgb,var(--primary,#222) 68%,var(--ldp-loader-accent)); font:700 10px/25px ui-monospace,monospace;border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent); animation:ldp-loader-markdown-source 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);} .ldp-loader-typewave span:nth-child(-n+3)::before{content:attr(data-source);} .ldp-loader-typewave span:nth-child(n+4){left:157px;top:calc(47px + var(--row) * 34px);width:92px;color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-accent)); font:650 11px/25px ui-sans-serif,system-ui,sans-serif;animation:ldp-loader-markdown-render 4.8s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--row) * .12s);} .ldp-loader-typewave span:nth-child(n+4)::before{content:attr(data-render);} .ldp-loader-typewave span:nth-child(4){height:28px;font-size:17px;font-weight:790;line-height:28px;border-bottom:2px solid color-mix(in srgb,var(--ldp-loader-brand) 78%,transparent);} .ldp-loader-typewave span:nth-child(5){font-weight:820;background:linear-gradient(transparent 59%,color-mix(in srgb,var(--ldp-loader-brand) 45%,transparent) 59% 91%,transparent 91%);} .ldp-loader-typewave span:nth-child(6){border-left:3px solid var(--ldp-loader-brand);padding-left:9px;color:color-mix(in srgb,var(--primary,#222) 61%,var(--ldp-loader-accent));font-style:italic;} @keyframes ldp-loader-markdown-scan{0%,10%{transform:translateX(0) scaleY(.5);opacity:0}24%{opacity:1}64%{transform:translateX(135px) scaleY(1);opacity:1}82%,100%{transform:translateX(135px) scaleY(.72);opacity:0}} @keyframes ldp-loader-markdown-source{0%,8%{transform:translate3d(-15px,0,0);opacity:0}22%,40%{transform:translate3d(0,0,0);opacity:1}58%,100%{transform:translate3d(26px,0,0);opacity:0}} @keyframes ldp-loader-markdown-render{0%,45%{transform:translate3d(-24px,4px,0) scale(.94);opacity:0}62%,84%{transform:translate3d(0,0,0) scale(1);opacity:1}100%{transform:translate3d(7px,-2px,0) scale(.98);opacity:0}} .ldp-loading-visual[data-animation="crystal"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 58%,var(--tertiary,#47855f));} .ldp-loader-cache-lanes{background:linear-gradient(180deg,transparent 50px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 51px,transparent 52px 83px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 84px,transparent 85px 116px,color-mix(in srgb,var(--ldp-loader-accent) 14%,transparent) 117px,transparent 118px);} .ldp-loader-cache-lanes::before{content:"CACHE\\A REUSE";position:absolute;left:111px;top:45px;width:58px;height:79px;display:grid;place-items:center;z-index:2;white-space:pre; border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent);border-right:1px solid color-mix(in srgb,var(--ldp-loader-accent) 30%,transparent); background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--secondary,#fff) 94%,var(--ldp-loader-brand)) 28% 72%,transparent); color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent));font:790 8px/1.65 ui-monospace,monospace;letter-spacing:.12em;text-align:center;} .ldp-loader-cache-lanes::after{content:"MISS HIT";position:absolute;left:31px;top:23px;width:218px;color:color-mix(in srgb,var(--primary,#222) 43%,transparent); font:740 7px/1 ui-monospace,monospace;letter-spacing:.12em;white-space:pre;} .ldp-loader-cache-lanes span{left:34px;top:calc(49px + var(--lane) * 33px);z-index:1;width:13px;height:3px;border-radius:99px;background:color-mix(in srgb,var(--ldp-loader-accent) 74%,transparent); box-shadow:0 0 0 3px color-mix(in srgb,var(--ldp-loader-accent) 5%,transparent);animation:ldp-loader-cache-packet 4.8s cubic-bezier(.65,0,.35,1) infinite both;animation-delay:calc(var(--i) * -.4s);} .ldp-loader-cache-lanes span:nth-child(3n+2){animation-direction:reverse;} .ldp-loader-cache-lanes span:nth-child(4n+1){width:16px;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 10%,transparent);} .ldp-loader-cache-lanes span:nth-child(4n+3){width:7px;opacity:.72;} @keyframes ldp-loader-cache-packet{0%,5%{transform:translate3d(0,0,0) scaleX(.72);opacity:.16}14%{opacity:.9}43%{transform:translate3d(96px,0,0) scaleX(1);opacity:1}54%{transform:translate3d(110px,0,0) scaleX(.82);opacity:.72}88%{transform:translate3d(196px,0,0) scaleX(1);opacity:.74}100%{transform:translate3d(196px,0,0) scaleX(.72);opacity:.16}} .ldp-loading-visual[data-animation="marginalia"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 72%,var(--tertiary,#47855f));} .ldp-loader-marginalia::before{content:"只看楼主";position:absolute;left:27px;top:18px;color:color-mix(in srgb,var(--primary,#222) 66%,var(--ldp-loader-accent));font:800 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.18em;} .ldp-loader-marginalia::after{content:"";position:absolute;left:23px;top:39px;width:3px;height:106px;border-radius:99px;background:linear-gradient(180deg,var(--ldp-loader-brand),transparent);animation:ldp-loader-op-scan 4.2s cubic-bezier(.65,0,.35,1) infinite;} .ldp-loader-marginalia span{left:41px;top:calc(36px + var(--i) * 24px);width:203px;height:18px;border-bottom:1px solid color-mix(in srgb,var(--primary,#222) 19%,transparent); color:color-mix(in srgb,var(--primary,#222) 56%,transparent);animation:ldp-loader-op-muted 4.2s ease-in-out infinite;animation-delay:calc(var(--i) * -.13s);} .ldp-loader-marginalia span::before{content:attr(data-user);position:absolute;left:0;bottom:4px;font:750 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.08em;} .ldp-loader-marginalia span::after{content:attr(data-floor);position:absolute;right:0;bottom:4px;font:700 8px/1 ui-monospace,monospace;} .ldp-loader-marginalia span[data-user="OP"]{border-color:color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent);color:color-mix(in srgb,var(--primary,#222) 88%,var(--ldp-loader-brand));animation-name:ldp-loader-op-focus;} .ldp-loader-marginalia span[data-user="OP"]::before{color:#202124;background:var(--ldp-loader-brand);padding:2px 5px;clip-path:polygon(4% 9%,96% 0,100% 87%,0 100%);} @keyframes ldp-loader-op-scan{0%,8%{transform:translateY(0) scaleY(.3);opacity:0}35%{opacity:1}84%,100%{transform:translateY(94px) scaleY(1);opacity:0}} @keyframes ldp-loader-op-muted{0%,20%{transform:translateX(0);opacity:.62}48%,76%{transform:translateX(18px);opacity:.12}100%{transform:translateX(25px);opacity:0}} @keyframes ldp-loader-op-focus{0%,16%{transform:translateX(-12px);opacity:0}40%,78%{transform:translateX(0);opacity:1}100%{transform:translateX(7px);opacity:0}} .ldp-loading-visual[data-animation="chapters"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 66%,var(--tertiary,#47855f));} .ldp-loader-index-fan::before{content:"TOPIC / INDEX";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 45%,transparent);font:760 8px/1 ui-monospace,monospace;letter-spacing:.17em;} .ldp-loader-index-fan::after{content:"";position:absolute;left:30px;top:50px;width:217px;height:2px;transform-origin:left center; background:linear-gradient(90deg,var(--ldp-loader-brand),color-mix(in srgb,var(--ldp-loader-brand) 35%,transparent),transparent);animation:ldp-loader-index-rule 5s cubic-bezier(.16,1,.3,1) infinite;} .ldp-loader-index-fan span{left:calc(29px + var(--i) * 45px);top:calc(40px + var(--i) * 15px);width:38px;height:67px;color:color-mix(in srgb,var(--primary,#222) 62%,var(--ldp-loader-accent)); border-left:1px solid color-mix(in srgb,var(--ldp-loader-accent) 19%,transparent);transform-origin:left bottom;animation:ldp-loader-index-leaf 5s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .075s);} .ldp-loader-index-fan span::before{content:attr(data-index);position:absolute;left:7px;top:1px;font:700 14px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.03em;} .ldp-loader-index-fan span::after{content:attr(data-tag);position:absolute;left:7px;bottom:5px;white-space:nowrap;font:650 8px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.1em;} .ldp-loader-index-fan span:first-child{border-color:var(--ldp-loader-brand);color:color-mix(in srgb,var(--primary,#222) 78%,var(--ldp-loader-brand));} .ldp-loader-index-fan span:first-child::before{font-size:18px;font-weight:760;} @keyframes ldp-loader-index-rule{0%,8%{transform:rotate(14deg) scaleX(.08);opacity:.18}30%,74%{transform:rotate(14deg) scaleX(1);opacity:.9}88%,100%{transform:rotate(14deg) scaleX(.58);opacity:.2}} @keyframes ldp-loader-index-leaf{0%,8%{transform:translate3d(-7px,14px,0) rotate(-3deg);opacity:.2}26%,74%{transform:translate3d(0,0,0) rotate(0);opacity:.96}88%,100%{transform:translate3d(2px,-3px,0) rotate(.6deg);opacity:.38}} .ldp-loading-visual[data-animation="quoteecho"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 82%,var(--secondary,#fff));} .ldp-loader-quoteecho::before{content:"LINUX DO";position:absolute;left:87px;top:27px;width:106px;color:color-mix(in srgb,var(--primary,#222) 46%,transparent);font:850 10px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.26em;text-align:center;} .ldp-loader-quoteecho::after{content:"";position:absolute;left:54px;right:54px;top:117px;height:3px;transform-origin:center;background:linear-gradient(90deg,transparent 0,var(--ldp-loader-brand) 18% 82%,transparent 100%);animation:ldp-loader-creed-line 5.6s cubic-bezier(.65,0,.35,1) infinite;} .ldp-loader-quoteecho span{left:calc(35px + var(--i) * 57px);top:62px;width:50px;height:32px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-accent)); font:750 17px/1 ui-serif,"Songti SC","Noto Serif CJK SC",serif;letter-spacing:.12em;animation:ldp-loader-creed-word 5.6s cubic-bezier(.16,1,.3,1) infinite both;} .ldp-loader-quoteecho span:nth-child(2){animation-name:ldp-loader-creed-word-2;} .ldp-loader-quoteecho span:nth-child(3){animation-name:ldp-loader-creed-word-3;} .ldp-loader-quoteecho span:nth-child(4){animation-name:ldp-loader-creed-word-4;} .ldp-loader-quoteecho span::before{content:attr(data-word);} .ldp-loader-quoteecho span::after{content:"";position:absolute;left:23px;bottom:-15px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);} @keyframes ldp-loader-creed-line{0%,26%{transform:scaleX(.02);opacity:0}40%,78%{transform:scaleX(1);opacity:1}86%,100%{transform:scaleX(.02);opacity:0}} @keyframes ldp-loader-creed-word{0%,8%{transform:translateY(11px) scale(.95);opacity:0}28%,72%{transform:translateY(0) scale(1);opacity:1}80%,100%{transform:translateY(-5px) scale(.98);opacity:0}} @keyframes ldp-loader-creed-word-2{0%,14%{transform:translateY(11px) scale(.95);opacity:0}34%,74%{transform:translateY(0) scale(1);opacity:1}82%,100%{transform:translateY(-5px) scale(.98);opacity:0}} @keyframes ldp-loader-creed-word-3{0%,20%{transform:translateY(11px) scale(.95);opacity:0}40%,76%{transform:translateY(0) scale(1);opacity:1}84%,100%{transform:translateY(-5px) scale(.98);opacity:0}} @keyframes ldp-loader-creed-word-4{0%,26%{transform:translateY(11px) scale(.95);opacity:0}46%,78%{transform:translateY(0) scale(1);opacity:1}86%,100%{transform:translateY(-5px) scale(.98);opacity:0}} .ldp-loading-visual[data-animation="footnotes"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 62%,var(--tertiary,#47855f));} .ldp-loader-inbox-rain::before{content:"NEW / REPLIES";position:absolute;left:28px;top:18px;color:color-mix(in srgb,var(--primary,#222) 48%,var(--ldp-loader-accent)); font:790 8px/1 ui-monospace,monospace;letter-spacing:.16em;border-left:2px solid var(--ldp-loader-brand);padding-left:9px;} .ldp-loader-inbox-rain::after{content:"";position:absolute;left:30px;top:124px;width:220px;height:1px;transform-origin:left center; background:linear-gradient(90deg,color-mix(in srgb,var(--ldp-loader-accent) 35%,transparent),color-mix(in srgb,var(--ldp-loader-brand) 68%,transparent) 74%,transparent); animation:ldp-loader-inbox-rule 5.2s cubic-bezier(.16,1,.3,1) infinite;} .ldp-loader-inbox-rain span{left:var(--x);top:var(--y);width:37px;height:27px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 74%,var(--ldp-loader-accent)); border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent);font:740 9px/1 ui-monospace,monospace;animation:ldp-loader-inbox-note 5.2s cubic-bezier(.16,1,.3,1) infinite both;animation-delay:calc(var(--i) * .1s);} .ldp-loader-inbox-rain span::before{content:attr(data-floor);} .ldp-loader-inbox-rain span::after{content:"";position:absolute;right:0;bottom:-2px;width:4px;height:4px;border-radius:50%;background:var(--ldp-loader-brand);box-shadow:0 0 0 4px color-mix(in srgb,var(--ldp-loader-brand) 9%,transparent);} .ldp-loader-inbox-rain span:last-child{color:color-mix(in srgb,var(--primary,#222) 84%,var(--ldp-loader-brand));border-color:var(--ldp-loader-brand);} @keyframes ldp-loader-inbox-rule{0%,8%{transform:rotate(-18deg) scaleX(.08);opacity:.16}28%,76%{transform:rotate(-18deg) scaleX(1);opacity:.9}90%,100%{transform:rotate(-18deg) scaleX(.62);opacity:.22}} @keyframes ldp-loader-inbox-note{0%,8%{transform:translate3d(0,var(--drop),0) scale(.96);opacity:.18}28%,76%{transform:translate3d(0,0,0) scale(1);opacity:1}90%,100%{transform:translate3d(3px,5px,0) scale(.98);opacity:.34}} .ldp-loading-visual[data-animation="inkverse"]{--ldp-loader-accent:color-mix(in srgb,var(--primary,#222) 74%,var(--tertiary,#47855f));} .ldp-loader-inkverse::before{content:"DO";position:absolute;left:117px;top:57px;width:46px;height:46px;display:grid;place-items:center;z-index:1; border-radius:43% 57% 61% 39% / 39% 44% 56% 61%;background:radial-gradient(circle at 72% 27%,#202124 0 3px,transparent 3.5px),var(--ldp-loader-brand); color:#202124;font:900 12px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-indent:.12em; box-shadow:-8px 9px 0 color-mix(in srgb,var(--ldp-loader-brand) 13%,transparent),0 0 0 1px color-mix(in srgb,#202124 12%,transparent);animation:ldp-loader-action-core 4.4s ease-in-out infinite;} .ldp-loader-inkverse::after{content:"";position:absolute;left:48px;top:25px;width:184px;height:120px;border-radius:50%; background:conic-gradient(from 18deg,transparent 0 11%,color-mix(in srgb,var(--ldp-loader-accent) 29%,transparent) 11% 27%,transparent 27% 41%,color-mix(in srgb,var(--ldp-loader-accent) 22%,transparent) 41% 67%,transparent 67% 80%,var(--ldp-loader-brand) 80% 83%,transparent 83% 100%); -webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px));mask:radial-gradient(farthest-side,transparent calc(100% - 1px),#000 calc(100% - 1px)); animation:ldp-loader-action-orbit 16s linear infinite;} .ldp-loader-inkverse span{left:var(--x);top:var(--y);width:56px;height:21px;display:grid;place-items:center;color:color-mix(in srgb,var(--primary,#222) 72%,var(--ldp-loader-accent)); border-bottom:1px solid color-mix(in srgb,var(--ldp-loader-accent) 18%,transparent);font:760 9px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.06em; animation:ldp-loader-action-label 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);} .ldp-loader-inkverse span::before{content:attr(data-action);} .ldp-loader-inkverse span::after{content:"";position:absolute;left:25px;top:25px;width:3px;height:3px;border-radius:50%;background:var(--ldp-loader-brand); animation:ldp-loader-action-pulse 4.4s cubic-bezier(.22,1,.36,1) infinite;animation-delay:calc(var(--i) * -.31s);} .ldp-loader-inkverse span:nth-child(1){--x:91px;--y:12px;--mx:29px;--my:25px;--dx:21px;--dy:58px} .ldp-loader-inkverse span:nth-child(2){--x:207px;--y:50px;--mx:-43px;--my:24px;--dx:-95px;--dy:20px} .ldp-loader-inkverse span:nth-child(3){--x:139px;--y:137px;--mx:-24px;--my:-36px;--dx:-27px;--dy:-67px} .ldp-loader-inkverse span:nth-child(4){--x:17px;--y:94px;--mx:47px;--my:-25px;--dx:95px;--dy:-24px} @keyframes ldp-loader-action-core{0%,100%{transform:scale(.9);opacity:.62}48%{transform:scale(1.08);opacity:1}} @keyframes ldp-loader-action-orbit{0%{transform:rotate(-8deg) scale(.98)}50%{transform:rotate(172deg) scale(1.02)}100%{transform:rotate(352deg) scale(.98)}} @keyframes ldp-loader-action-label{0%,16%{transform:translateY(5px) scale(.92);opacity:.22}38%,80%{transform:translateY(0) scale(1);opacity:.9}100%{transform:translateY(-3px) scale(.96);opacity:.22}} @keyframes ldp-loader-action-pulse{0%,18%{transform:translate3d(0,0,0) scale(.65);opacity:0}38%{opacity:1}62%{transform:translate3d(var(--mx),var(--my),0) scale(1);opacity:.9}82%{transform:translate3d(var(--dx),var(--dy),0) scale(1.45);opacity:1}100%{transform:translate3d(var(--dx),var(--dy),0) scale(.35);opacity:0}} @keyframes ldp-loader-reduced-breathe{0%,100%{opacity:.76}50%{opacity:1}} @media (max-width:380px){ .ldp-loading-visual{transform:scale(.88);transform-origin:center center;} } @media (prefers-reduced-motion: reduce){ :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner, :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::before, :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner::after, :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span, :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::before, :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner span::after{animation:none!important;will-change:auto!important;} :is(.ldp-loadmask,.ldp-loading-settings-preview) .ldp-loading-visual-inner{animation:ldp-loader-reduced-breathe 2.5s ease-in-out infinite!important;} } /* 楼层区分隔 + 左上角标题 */ .ldp-comments-header{display:flex;align-items:center;gap:8px; margin:6px 0 2px;padding-top:14px;border-top:var(--ldp-divider-line-emphasis-width,2px) solid var(--ldp-divider-line-color,#e5e5e5); font-size:var(--ldp-font-lg,15px);font-weight:700;letter-spacing:.5px;} .ldp-comments-count{font-size:var(--ldp-font-ui,12px);font-weight:500;opacity:.6;} .ldp-topic-presence{display:inline-flex;align-items:center;gap:7px;min-width:0;max-width:min(55%,18rem); margin-left:auto;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-weight:500;letter-spacing:0;} .ldp-topic-presence-avatars{display:flex;flex:none;align-items:center;padding-left:4px;} .ldp-topic-presence-user{display:inline-flex;margin-left:-4px;line-height:0;} .ldp-topic-presence-user:first-child{margin-left:0;} .ldp-topic-presence-avatar{box-sizing:border-box;width:22px;height:22px;border:2px solid var(--secondary,#fff); border-radius:50%;object-fit:cover;background:var(--primary-low,#ddd);} .ldp-topic-presence-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-comments{--ldp-nested-content-scale:.97;--ldp-nested-component-scale:.95;--ldp-nested-component-gap:7.6px;padding-top:4px;} .ldp-comments-empty{padding:18px 0;text-align:center;opacity:.5;font-size:var(--ldp-font-base,13px);} .ldp-post{padding:12px 16px;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5); content-visibility:auto;contain-intrinsic-size:auto 140px;} .ldp-overlay.ldp-initial-target-layout .ldp-post{content-visibility:visible;} .ldp-comments > .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed){ border-radius:var(--ldp-zebra-radius,10px);border-bottom-color:transparent;background:var(--ldp-zebra-color,honeydew);} .ldp-comments > .ldp-post:has(+ .ldp-post.ldp-zebra-alt:not(.ldp-nested-preview):not(.ldp-nested-collapsed)){ border-bottom-color:transparent;} .ldp-comments > .ldp-post.ldp-nested-preview{background:transparent;} .ldp-stream-spacer{height:var(--ldp-stream-spacer-height,64px); border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);box-sizing:border-box;pointer-events:none;overflow-anchor:none;} .ldp-stream-spacer-top:has(+ .ldp-post[data-post-number="1"]){border-bottom-color:transparent;} .ldp-content-pending{overflow-anchor:none;} .ldp-post-head{display:flex;align-items:center;gap:8px;min-width:0;margin-bottom:5px;} .ldp-avatar{width:28px;height:28px;border-radius:50%;} .ldp-avatar-with-flair{position:relative;display:inline-flex;flex:none;line-height:0;} .ldp-avatar-flair{position:absolute;right:-3px;bottom:-2px;display:grid;box-sizing:border-box; width:13px;height:13px;place-items:center;overflow:hidden;pointer-events:auto;cursor:copy;border:1.5px solid var(--secondary,#fff); border-radius:50%;background:var(--ldp-flair-bg,var(--primary-low,#d9e1ec));color:var(--ldp-flair-color,var(--primary,#334155));} .ldp-avatar-flair-icon{display:block;width:70%;height:70%;fill:currentColor;stroke:none;} .ldp-avatar-flair-image{display:block;width:100%;height:100%;object-fit:contain;} .ldp-reply.ldp-nested-collapsed .ldp-avatar-flair{right:-2px;bottom:-1px;width:10px;height:10px;border-width:1px;} .ldp-persistent-avatar-fallback{display:inline-flex;align-items:center;justify-content:center; overflow:hidden;flex:none;background:var(--primary-low,#ddd);color:var(--primary-medium,#666); font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;} .ldp-user-link{color:inherit;text-decoration:none;cursor:pointer;} .ldp-user-link:hover{color:var(--tertiary,#08c);text-decoration:underline;} .ldp-user-avatar-card{cursor:pointer;} span.ldp-user-avatar-card{display:inline-flex;line-height:0;flex:none;} .ldp-avatar-link{display:inline-flex;padding:0;border:0;background:transparent;color:inherit;line-height:0;cursor:zoom-in;} .ldp-author{font-weight:600;min-width:0;max-width:min(20rem,40%); overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-op{display:inline-flex;align-items:center;flex:none;font-size:var(--ldp-font-sm,11px);font-weight:700;color:#fff;background:var(--tertiary,#08c); border-radius:4px;padding:1px 6px;letter-spacing:.5px;} .ldp-me{font-size:var(--ldp-font-sm,11px);font-weight:700;color:#fff;background:#3ea66b; border-radius:4px;padding:1px 6px;letter-spacing:.5px;} .ldp-user{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);opacity:.6;min-width:0;max-width:min(14rem,25%); overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-new-user-badge{display:inline-flex;align-items:center;justify-content:center;gap:4px;flex:none; min-height:21px;padding:2px 7px 2px 5px;box-sizing:border-box; border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 42%,transparent); border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff)); color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1;white-space:nowrap;} .ldp-new-user-badge .ldp-icon{width:12px;height:12px;stroke-width:2.35;} .ldp-new-user-badge:hover{background:color-mix(in srgb,var(--tertiary,#47855f) 27%,var(--secondary,#fff));} .ldp-time{position:relative;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);white-space:nowrap;flex:none;} .ldp-time-relative{opacity:.55;} .ldp-time[data-exact-time]::after{content:attr(data-exact-time);position:absolute;left:calc(100% + 6px);top:50%;z-index:5; transform:translateY(-50%);display:none;color:inherit;font:inherit;line-height:inherit;opacity:.55; white-space:nowrap;pointer-events:none;} .ldp-time[data-exact-time]:hover::after{display:block;} .ldp-floor{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);line-height:1.35;color:var(--primary-medium,#777); opacity:1;margin-left:auto;padding-left:8px;white-space:nowrap;} .ldp-post-read-state{display:inline-flex;flex:none;width:12px;height:12px;align-items:center;justify-content:center; color:var(--tertiary,#47855f);line-height:1;} .ldp-post-read-state .ldp-icon{width:9px;height:9px;stroke-width:2.6;} .ldp-post-read-state.is-read{opacity:.72;} .ldp-post-read-state.is-unread .ldp-icon{stroke:none;fill:currentColor;} .ldp-post-read-state.is-confirming{animation:ldp-post-read-confirm 1.25s ease-in-out both;} @keyframes ldp-post-read-confirm{ 0%,100%{opacity:1;transform:scale(1);} 50%{opacity:.12;transform:scale(.72);} } @media (prefers-reduced-motion:reduce){ .ldp-post-read-state.is-confirming{animation:none;} } .ldp-content{overflow-x:auto;color:var(--ldp-post-font-color,var(--primary,#222));font-family:var(--ldp-post-font-family,inherit)!important; font-size:var(--ldp-post-font-size,14px);font-weight:var(--ldp-post-font-weight,400);padding-inline:8px;box-sizing:border-box;} .ldp-comments .ldp-post > .ldp-post-head{margin-bottom:0;} .ldp-comments .ldp-post > .ldp-content:not(.ldp-content-pending){padding-block:12px;} .ldp-comments .ldp-post > .ldp-content > :first-child{margin-top:0;} .ldp-comments .ldp-post > .ldp-content > :last-child{margin-bottom:0;} .ldp-content a,.ldp-content a:visited,.ldp-content a:hover,.ldp-content a:active{ color:var(--d-link-color,var(--tertiary,#08c));text-decoration:var(--d-link-text-decoration,none);cursor:pointer;} .ldp-content a.inline-onebox{display:inline-flex;max-width:min(100%,72ch);align-items:flex-start;gap:.28em; line-height:1.5;vertical-align:top;} .ldp-content a.inline-onebox > :is(svg,.svg-icon){width:.84em;height:.84em;flex:none;margin-top:.3em;opacity:.8;} .ldp-content a.inline-onebox > .ldp-inline-onebox-label{min-width:0;overflow:hidden; text-overflow:ellipsis;white-space:nowrap;} .ldp-content a.inline-onebox > .ldp-link-click-count{flex:none;margin-inline-start:0;margin-top:.12em;} .ldp-content .ldp-link-click-count{display:inline-flex;min-width:1.55em;height:1.55em;align-items:center;justify-content:center; margin-inline-start:.32em;padding:0 .42em;box-sizing:border-box;border-radius:999px; background:color-mix(in srgb,var(--primary,#222) 10%,var(--secondary,#fff));color:var(--primary-high,#444); font-size:.78em;font-weight:650;line-height:1;font-variant-numeric:tabular-nums;vertical-align:.08em;white-space:nowrap;} .ldp-content h1,.ldp-content h2,.ldp-content h3,.ldp-content h4,.ldp-content h5,.ldp-content h6{ margin-top:0;margin-bottom:.5rem;font-family:inherit!important;font-weight:var(--heading-font-weight,bold);} .ldp-content aside.onebox{display:block;width:min(100%,760px);max-width:100%;margin:.85em 0;padding:14px 16px; overflow:hidden;box-sizing:border-box;border:1px solid var(--primary-low,#e5e5e5);border-radius:7px; background:var(--secondary,#fff);color:var(--primary,#222); box-shadow:0 1px 2px rgba(0,0,0,.12),0 4px 8px rgba(0,0,0,.08);} .ldp-content aside.onebox > header.source{display:flex;align-items:center;gap:6px;min-width:0; margin:0 0 11px;color:var(--primary-medium,#777);font-size:.9em;font-weight:600;line-height:1.35;} .ldp-content aside.onebox > header.source :is(img,.site-icon){width:18px!important;height:18px!important; max-width:18px!important;margin:0!important;border-radius:4px;object-fit:cover;cursor:default;zoom:1;flex:none;} .ldp-content aside.onebox > header.source a{min-width:0;overflow:hidden;color:inherit; text-overflow:ellipsis;white-space:nowrap;text-decoration:none;} .ldp-content aside.onebox > article.onebox-body{min-width:0;margin:0;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]){width:min(100%,680px);margin-inline:auto;cursor:pointer;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body{ position:relative;display:grid;grid-template-columns:minmax(0,1fr);grid-auto-flow:row; min-height:0;align-content:start;align-items:start;gap:2px;overflow:hidden;} .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail), .ldp-content aside.onebox > article.onebox-body > :is(.aspect-image,.thumbnail) img, .ldp-content aside.onebox > article.onebox-body > img.thumbnail{display:none!important;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body > :has(img.thumbnail), .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body :has(> img.thumbnail){ display:contents!important;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body img.thumbnail{ display:none!important;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body :is(h3,p,.onebox-metadata){ position:relative;z-index:1;grid-column:1;min-width:0;background:transparent;text-align:left;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body > :has(h3){ position:relative;z-index:1;grid-column:1;min-width:0;justify-self:stretch;text-align:left;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body > :has(h3) :is(h3,p,.onebox-metadata){ text-align:left;} .ldp-content aside.onebox .onebox-body h3{margin:0;font-size:1em; line-height:1.38;text-wrap:pretty;} .ldp-content aside.onebox .onebox-body h3 a{font-weight:650;text-decoration:none;} .ldp-content aside.onebox :is(header.source,.onebox-body h3) :is(svg,.svg-icon){width:.9em;height:.9em;flex:none;} .ldp-content aside.onebox .onebox-body p{margin-top:6px;margin-bottom:0;color:var(--primary-medium,#666); font-size:.92em;line-height:1.5;} .ldp-content aside.onebox .onebox-metadata{margin:6px 0 0;color:var(--primary-medium,#777);font-size:.8em;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) .onebox-body h3{ font-size:.88em;line-height:1.32;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) .onebox-body p{ margin-top:2px;font-size:.8em;line-height:1.42;} .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) .onebox-metadata{font-size:.72em;} .ldp-content aside.onebox .ldp-link-click-count{min-width:1.4em;height:1.4em;padding:0 .36em;font-size:.72em;} @container ldp-reader (max-width:560px){ .ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"]) > article.onebox-body{ overflow:visible;} } .ldp-content hr{display:block;height:1px;margin:1em 0;padding:0;border:0; border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-content blockquote{box-sizing:border-box;margin:1em 0;padding:.75em 1em; border:0;border-inline-start:var(--ldp-quote-line-width,1px) solid var(--ldp-quote-line-color,#d7d7d7); border-radius:0 6px 6px 0; background:color-mix(in srgb,var(--primary,#222) 3%,var(--secondary,#fff)); color:var(--primary,#222);line-height:1.65;} .ldp-content blockquote > :first-child{margin-top:0;} .ldp-content blockquote > :last-child{margin-bottom:0;} .ldp-content blockquote.ldp-callout{--ldp-callout-color:#2f78ef;position:relative;margin:1em 0; padding:.8em 1em .8em 2.8em;border:0;border-radius:6px; background:color-mix(in srgb,var(--ldp-callout-color) 12%,var(--secondary,#fff));color:var(--primary,#222);} .ldp-content blockquote:is(.ldp-callout--success,.ldp-callout--check,.ldp-callout--done){--ldp-callout-color:#2f9e64;} .ldp-content blockquote:is(.ldp-callout--question,.ldp-callout--help,.ldp-callout--faq){--ldp-callout-color:#d99c00;} .ldp-content blockquote:is(.ldp-callout--warning,.ldp-callout--caution,.ldp-callout--attention){--ldp-callout-color:#e87524;} .ldp-content blockquote.ldp-callout--example{--ldp-callout-color:#7658d6;} .ldp-content blockquote:is(.ldp-callout--failure,.ldp-callout--fail,.ldp-callout--missing, .ldp-callout--danger,.ldp-callout--error,.ldp-callout--bug){--ldp-callout-color:#d64545;} .ldp-content blockquote:is(.ldp-callout--quote,.ldp-callout--cite){--ldp-callout-color:#6f7782;} .ldp-content blockquote.ldp-callout > .ldp-callout-icon{position:absolute;left:1em;top:.9em; display:inline-flex;width:1.1em;height:1.1em;align-items:center;justify-content:center;color:var(--ldp-callout-color);} .ldp-content blockquote.ldp-callout > .ldp-callout-icon .ldp-icon{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:2;} .ldp-content blockquote.ldp-callout > .ldp-callout-icon + *{margin-top:0;color:var(--ldp-callout-color);font-weight:700;} .ldp-content blockquote.ldp-callout > :last-child{margin-bottom:0;} .ldp-content blockquote.ldp-callout--foldable{padding-right:3em;} .ldp-content .ldp-callout-toggle{position:absolute;right:.65em;top:.55em;display:inline-flex;width:1.8em;height:1.8em; align-items:center;justify-content:center;padding:0;border:0;border-radius:5px;background:transparent; color:var(--ldp-callout-color);cursor:pointer;} .ldp-content .ldp-callout-toggle:hover{background:color-mix(in srgb,var(--ldp-callout-color) 12%,transparent);} .ldp-content .ldp-callout-body{margin-top:.6em;} .ldp-content aside.ldp-post-quote{position:relative;box-sizing:border-box;width:100%;height:auto!important;max-height:none!important; margin:1em 0;padding:12px 14px;overflow:visible; border-left:var(--ldp-quote-line-emphasis-width,5px) solid var(--ldp-quote-line-color,#d7d7d7);background:var(--d-post-aside-background,var(--primary-very-low,#f6f6f6));} .ldp-content aside.ldp-post-quote > .ldp-quote-title{display:flex;align-items:center;gap:8px;min-height:28px; margin:0 0 10px;padding-right:70px;color:var(--primary-medium,#666);font-size:1em;line-height:1.35;} .ldp-content aside.ldp-post-quote > .ldp-quote-title :is(img,.avatar){width:24px!important;height:24px!important; margin:0!important;border-radius:50%;object-fit:cover;} .ldp-content aside.ldp-post-quote > .ldp-quote-title a{color:inherit;font-weight:650;text-decoration:none;} .ldp-content aside.ldp-post-quote > blockquote{height:auto!important;max-height:none!important;margin:0;padding:0; overflow:visible!important;border:0;background:transparent;} .ldp-content aside.ldp-post-quote.ldp-quote-expanded > blockquote aside.ldp-post-quote{display:block!important;} .ldp-content aside.ldp-post-quote.ldp-quote-expanded{padding-bottom:52px;} .ldp-content .ldp-quote-collapse-bottom{position:absolute;right:14px;bottom:12px;display:inline-flex;width:30px;height:30px; align-items:center;justify-content:center;margin:0;padding:0;border:0;border-radius:6px; background:color-mix(in srgb,var(--primary,#222) 5%,transparent);color:var(--primary-medium,#777);cursor:pointer;} .ldp-content .ldp-quote-collapse-bottom:hover{background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222);} .ldp-content aside.ldp-post-quote > .ldp-quote-title > .ldp-quote-controls{position:absolute;top:10px;right:12px; display:flex;align-items:center;gap:4px;margin:0;} .ldp-content .ldp-quote-controls button{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center; margin:0;padding:0;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-content .ldp-quote-controls button:hover{background:color-mix(in srgb,var(--primary,#222) 6%,transparent);color:var(--primary,#222);} .ldp-content .ldp-quote-controls button:disabled{cursor:wait;opacity:.45;} .ldp-content .ldp-quote-controls .ldp-quote-jump{background:color-mix(in srgb,var(--tertiary,#47855f) 13%,transparent);color:var(--tertiary,#47855f);} .ldp-content .ldp-quote-collapse-bottom .ldp-icon, .ldp-content .ldp-quote-controls .ldp-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2.2;} .ldp-content mark.ldp-quote-match{padding:0 .08em;border-radius:3px;background:color-mix(in srgb,#ffd85a 68%,transparent); color:inherit;box-decoration-break:clone;-webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);cursor:pointer;} .ldp-content mark.ldp-expanded-quote-match{padding:0 .08em;border-radius:3px; background:color-mix(in srgb,#ffd85a 68%,transparent);color:inherit;box-decoration-break:clone; -webkit-box-decoration-break:clone;box-shadow:0 0 0 2px color-mix(in srgb,#d99c00 24%,transparent);} .ldp-content mark.ldp-quote-match.ldp-quote-match-muted{background:transparent;box-shadow:none; text-decoration-line:underline;text-decoration-style:dotted;text-decoration-thickness:1.5px; text-decoration-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);text-underline-offset:.18em;} .ldp-content mark.ldp-quote-match-end{position:relative;} .ldp-content mark.ldp-quote-match-end:focus{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:3px;} .ldp-quote-highlight-hint{position:fixed;z-index:2147483646;display:none; align-items:center;gap:2px;padding:3px;pointer-events:auto;} .ldp-quote-highlight-hint.ldp-quote-hint-visible{display:flex;} .ldp-quote-highlight-hint button{margin:0;padding:3px 6px;border:0;border-radius:5px;background:transparent; color:inherit;font:inherit;line-height:1.35;white-space:nowrap;cursor:pointer;} .ldp-quote-highlight-hint button:hover, .ldp-quote-highlight-hint button:focus-visible{ background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff)); color:var(--tertiary,#47855f);outline:none;} .ldp-quote-highlight-return{border-left:1px solid var(--primary-low,#ddd)!important;border-radius:0 5px 5px 0!important;} .ldp-content ul,.ldp-content ol,.ldp-content dd{clear:both;margin:1em 0 1em 1.25em;padding-inline-start:1.25em;} .ldp-content li > ul,.ldp-content li > ol{margin:0;} .ldp-content code,.ldp-content pre,.ldp-content kbd{font-family:var(--d-font-family--monospace,monospace);} .ldp-content table{max-width:100%;border-collapse:collapse;} .ldp-content.ldp-content-pending{position:relative;min-height:var(--ldp-content-placeholder-height,72px); overflow:hidden;contain:layout paint;pointer-events:none;} .ldp-content.ldp-content-pending::before{content:"";display:block;width:min(78%,620px);height:52px; margin:7px 0;border-radius:4px;opacity:.52;background: linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 0/72% 8px no-repeat, linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 21px/100% 8px no-repeat, linear-gradient(var(--primary-low,#e5e5e5) 0 0) 0 42px/56% 8px no-repeat;} :is(.ldp-content,.ldp-solved-excerpt) img:not(.emoji){max-width:100%;height:auto;cursor:zoom-in;border-radius:4px; zoom:var(--ldp-image-zoom,1);} .ldp-content img.emoji{max-height:1lh;width:auto;object-fit:contain;} .ldp-content svg.svg-icon{display:inline-block;width:1em;height:1em;vertical-align:-.125em;fill:currentColor;} .ldp-content .hashtag-cooked{display:inline-flex;align-items:center;max-width:100%;padding:.06em .45em .08em; border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 9%,transparent);color:var(--primary,#222)!important; line-height:1.35;vertical-align:-.12em;text-decoration:none!important;} .ldp-content .hashtag-cooked > a{display:inline-flex;align-items:center;max-width:100%;color:inherit!important;text-decoration:none!important;} .ldp-content .hashtag-cooked:hover{background:color-mix(in srgb,var(--primary,#222) 14%,transparent);} .ldp-content .hashtag-cooked:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 55%,transparent);outline-offset:2px;} .ldp-content .hashtag-cooked .hashtag-icon-placeholder{display:none;} .ldp-content .hashtag-cooked img.emoji,.ldp-content .hashtag-cooked svg{width:.93em;height:.93em; margin-right:var(--space-1,.25em);vertical-align:-.125em;} :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper{display:block;max-width:100%;line-height:0;} :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper > a.lightbox{display:inline-block;max-width:100%;line-height:0;} :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper img:not(.emoji){display:block;} :is(.ldp-content,.ldp-solved-excerpt) .lightbox-wrapper .meta{display:none;} .ldp-content .ldp-media-carousel{max-width:100%;margin:1em 0;} .ldp-content .ldp-media-carousel-track{display:grid;grid-auto-flow:column;grid-auto-columns:100%;gap:10px; max-width:100%;overflow-x:auto;overscroll-behavior-inline:contain;scroll-snap-type:x mandatory;scrollbar-width:thin;} .ldp-content .ldp-media-carousel-track > .lightbox-wrapper{display:grid;min-width:0;place-items:center; overflow:hidden;border-radius:10px;background:var(--primary-very-low,#f5f5f5);scroll-snap-align:start;scroll-snap-stop:always;} .ldp-content .ldp-media-carousel-track > .lightbox-wrapper > a.lightbox{display:grid;width:100%;place-items:center;} .ldp-content .ldp-media-carousel-track img:not(.emoji){width:100%;max-height:min(72vh,760px);object-fit:contain;border-radius:10px;} .ldp-content .ldp-media-carousel-controls{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:8px;} .ldp-content .ldp-media-carousel-controls button{display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center; padding:0;border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:var(--primary-medium,#777);cursor:pointer;} .ldp-content .ldp-media-carousel-controls button:hover:not(:disabled){border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);} .ldp-content .ldp-media-carousel-controls button:disabled{opacity:.32;cursor:default;} .ldp-native-reader-trigger .ldp-icon,.ldp-native-boost-menu .discourse-boosts__emoji-btn .ldp-icon, .ldp-collection-delete .ldp-icon,.ldp-reader-history-nav .ldp-icon, .ldp-content .ldp-media-carousel-controls .ldp-icon{width:17px;height:17px;} .ldp-content .ldp-media-carousel-status{min-width:3.5em;color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);text-align:center;font-variant-numeric:tabular-nums;} .ldp-content video{display:block;width:min(100%,960px);height:auto;max-height:min(72vh,760px);margin:1em auto; border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);} .ldp-content audio{display:block;width:min(100%,760px);height:42px;margin:.9em 0;} .ldp-content iframe{max-width:100%;border:0;} .ldp-content iframe.ldp-bilibili-player{display:block;width:min(100%,960px);height:auto;aspect-ratio:16/9;margin:1em auto; border-radius:10px;background:#000;box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#222) 12%,transparent);} .ldp-content .ldp-image-retry{display:inline-flex;align-items:center;gap:6px;margin:8px 0;padding:7px 11px; border:1px solid var(--primary-low,#d9d9d9);border-radius:7px;background:var(--secondary,#fff); color:var(--primary,#222);font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.2;cursor:pointer;vertical-align:middle;} .ldp-content .ldp-image-retry:hover{border-color:var(--tertiary,#47855f);color:var(--tertiary,#47855f);} .ldp-content .ldp-image-retry:disabled{opacity:.55;cursor:wait;} .ldp-collection-action .ldp-icon,.ldp-settings-close .ldp-icon,.ldp-user-info-title-refresh .ldp-icon, .ldp-about-feature-icon .ldp-icon,.ldp-content .ldp-image-retry .ldp-icon{width:16px;height:16px;} .ldp-content .discourse-post-event{display:none;} .ldp-code-block{position:relative;max-width:100%;margin:1em 0;} .ldp-code-block > pre{margin:0;} .ldp-code-block-actions{position:absolute;z-index:2;top:7px;right:7px;display:flex;align-items:center;gap:4px;} .ldp-code-block-action{display:inline-flex;width:28px;height:28px;align-items:center;justify-content:center; margin:0;padding:0;border:1px solid color-mix(in srgb,var(--primary,#222) 8%,transparent);border-radius:6px; background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--primary-high,#444);cursor:pointer;} .ldp-code-block-action:hover,.ldp-code-block-action:focus-visible{border-color:color-mix(in srgb,var(--tertiary,#47855f) 35%,transparent); background:color-mix(in srgb,var(--tertiary,#47855f) 18%,var(--secondary,#fff));color:var(--tertiary,#47855f);outline:none;} .ldp-code-block-action .ldp-icon{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;} .ldp-content pre{overflow:auto;background:var(--primary-very-low,#f6f6f6); padding:10px;border-radius:6px;font-size:inherit;} .ldp-code-preview-layer{position:absolute;z-index:30;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0; background:var(--secondary,#fff);color:var(--primary,#222);} .ldp-code-preview-head{display:flex;flex:none;align-items:center;justify-content:space-between;gap:12px;min-height:50px; padding:8px 12px 8px 16px;border-bottom:1px solid var(--primary-low,#ddd);background:var(--header_background,var(--secondary,#fff));} .ldp-code-preview-head strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--ldp-font-md,14px);} .ldp-code-preview-actions{display:flex;align-items:center;gap:5px;} .ldp-code-preview-body{flex:1;min-width:0;min-height:0;padding:12px;overflow:auto;background:var(--primary-very-low,#f6f6f6);} .ldp-code-preview-body:has(.ldp-code-preview-editor){padding:0;overflow:hidden;} .ldp-code-preview-body pre,.ldp-code-preview-editor{box-sizing:border-box;margin:0;border:0;background:transparent; color:inherit;font-family:var(--ldp-code-preview-font-family,"Cascadia Code","JetBrains Mono",Consolas,"Liberation Mono",monospace); font-size:var(--ldp-code-preview-font-size,var(--ldp-font-base,13px));font-weight:var(--ldp-code-preview-font-weight,400); font-variant-ligatures:none;line-height:var(--ldp-code-preview-line-height,1.65);tab-size:var(--ldp-code-preview-tab-size,4);white-space:pre;} .ldp-code-preview-body pre *{font-family:inherit!important;font-size:inherit!important;line-height:inherit!important;} .ldp-code-preview-body pre{min-width:max-content;} .ldp-code-preview-editor{display:block;width:100%;height:100%;padding:12px;overflow:auto;overscroll-behavior:contain; resize:none;outline:none;touch-action:auto;} .ldp-content .ldp-reader-poll{margin:14px 0;padding:14px 16px;border:1px solid var(--primary-low,#ddd); border-radius:8px;background:var(--secondary,#fff);color:var(--primary,#222);} .ldp-poll-title{margin:0 0 12px;font-size:var(--ldp-font-xl,16px);font-weight:700;line-height:1.45;} .ldp-poll-options,.ldp-poll-results{display:grid;gap:8px;margin:0;padding:0;list-style:none;} .ldp-poll-option{display:flex;align-items:flex-start;gap:9px;padding:9px 10px;border:1px solid var(--primary-low,#ddd); border-radius:7px;cursor:pointer;line-height:1.45;background:var(--secondary,#fff);} .ldp-poll-option:hover{border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd)); background:var(--primary-very-low,#f7f7f7);} .ldp-poll-option:has(input:checked){border-color:var(--tertiary,#47855f); background:color-mix(in srgb,var(--tertiary,#47855f) 8%,var(--secondary,#fff));} .ldp-poll-option input{flex:none;margin:3px 0 0;accent-color:var(--tertiary,#47855f);} .ldp-poll-option-text{min-width:0;overflow-wrap:anywhere;} .ldp-poll-option-text p{display:inline;margin:0;} .ldp-poll-result{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;align-items:center;} .ldp-poll-result-label{min-width:0;overflow-wrap:anywhere;line-height:1.4;} .ldp-poll-result-label p{display:inline;margin:0;} .ldp-poll-result-value{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);white-space:nowrap;} .ldp-poll-result-track{grid-column:1/-1;height:8px;border-radius:999px;overflow:hidden; background:var(--primary-low,#e5e5e5);} .ldp-poll-result-bar{display:block;height:100%;min-width:0;border-radius:inherit;background:var(--tertiary,#47855f);} .ldp-poll-footer{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:13px;} .ldp-poll-meta{margin-right:auto;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);} .ldp-poll-button{min-height:32px;padding:5px 11px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px; background:var(--secondary,#fff);color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-base,13px);} .ldp-poll-button:hover:not(:disabled){background:var(--primary-very-low,#f2f2f2);} .ldp-poll-button-primary{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff;} .ldp-poll-button-primary:hover:not(:disabled){filter:brightness(.95);background:var(--tertiary,#47855f);} .ldp-poll-button:disabled,.ldp-reader-poll[aria-busy="true"] .ldp-poll-button, .ldp-reader-poll[aria-busy="true"] .ldp-poll-option{cursor:wait;opacity:.55;pointer-events:none;} .ldp-poll-note{margin:10px 0 0;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);} .ldp-floor-preview-post .ldp-reader-poll input,.ldp-floor-preview-post .ldp-reader-poll button{pointer-events:none;} .ldp-topic-vote{display:inline-flex;align-items:center;gap:6px;margin-left:6px;padding:3px 9px; border:1px solid var(--primary-low,#ddd);border-radius:999px;background:var(--secondary,#fff);color:inherit; cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;white-space:nowrap;} .ldp-topic-vote.on{border-color:var(--tertiary,#47855f);background:color-mix(in srgb,var(--tertiary,#47855f) 11%,var(--secondary,#fff));color:var(--tertiary,#47855f);} .ldp-topic-vote:disabled{cursor:wait;opacity:.55;} .ldp-special-badges{display:flex;flex-wrap:wrap;gap:5px;margin:0 8px 7px;} .ldp-special-badge{padding:2px 7px;border-radius:999px;background:var(--primary-very-low,#f3f3f3); color:var(--primary-medium,#666);font-size:var(--ldp-font-sm,11px);font-weight:700;} .ldp-post-head > .ldp-hidden-badge{flex:none;white-space:nowrap;} .ldp-special-badge.warn{background:#fff1d6;color:#8a5500}.ldp-special-badge.danger{background:#ffe5e5;color:#a12b2b} .ldp-special-notice{margin:8px;padding:9px 11px;border-left:var(--ldp-reply-line-emphasis-width,3px) solid var(--ldp-reply-line-color,#47855f); border-radius:4px;background:var(--primary-very-low,#f5f5f5);font-size:var(--ldp-font-base,13px);line-height:1.5;} .ldp-system-action{display:flex;align-items:center;gap:8px;margin:8px;padding:9px 11px; border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 28%,var(--primary-low,#ddd));border-radius:6px; background:color-mix(in srgb,var(--tertiary,#47855f) 7%,var(--secondary,#fff));color:var(--primary-high,#444); font-size:var(--ldp-font-base,13px);font-weight:650;line-height:1.5;} .ldp-system-action .ldp-icon{width:17px;height:17px;flex:none;color:var(--tertiary,#47855f);stroke-width:2;} .ldp-system-action-user{color:var(--tertiary,#47855f);font-weight:750;text-decoration:none;} .ldp-system-action-user:hover,.ldp-system-action-user:focus-visible{text-decoration:underline;} .ldp-post.ldp-system-action-compact{display:flex;align-items:center;gap:8px;min-width:0;padding-block:6px; overflow-x:auto;overflow-y:hidden;white-space:nowrap;} .ldp-post.ldp-system-action-compact > .ldp-post-head{display:contents;} .ldp-post.ldp-system-action-compact :is(.ldp-author,.ldp-user){max-width:none;flex:none;overflow:visible; text-overflow:clip;white-space:nowrap;} .ldp-post.ldp-system-action-compact > .ldp-post-head > :is(.ldp-user,.ldp-new-user-badge,.ldp-time,.ldp-floor), .ldp-post.ldp-system-action-compact > .ldp-reactions{color:var(--primary-medium,#777);opacity:.62;} .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-new-user-badge{ border-color:color-mix(in srgb,var(--primary,#222) 14%,transparent); background:color-mix(in srgb,var(--primary,#222) 4%,var(--secondary,#fff));font-weight:600;} .ldp-post.ldp-system-action-compact > .ldp-reactions:hover, .ldp-post.ldp-system-action-compact > .ldp-reactions:focus-within{opacity:.86;} .ldp-post.ldp-system-action-compact > .ldp-system-action{order:1;flex:none;margin:0;padding:0; border:0;border-radius:0;background:transparent;color:var(--primary-medium,#777); font-size:var(--ldp-font-ui,12px);font-weight:500;white-space:nowrap;} .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-icon{color:inherit;opacity:.78;} .ldp-post.ldp-system-action-compact > .ldp-system-action .ldp-system-action-user{color:inherit;font-weight:600;} .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-floor{order:2;flex:none;margin-left:auto;} .ldp-post.ldp-system-action-compact > .ldp-post-head > .ldp-post-read-state{order:2;} .ldp-post.ldp-system-action-compact > .ldp-content{display:none;} .ldp-solved-card{margin:14px 8px 4px;border:1px solid color-mix(in srgb,var(--success,#3f9625) 42%,var(--primary-low,#ddd)); border-radius:8px;overflow:hidden;background:var(--secondary,#fff);} .ldp-solved-head{display:flex;align-items:center;gap:7px;padding:8px 12px;background:var(--success,#3f9625); color:#fff;font-size:var(--ldp-font-md,14px);font-weight:750;line-height:1.3;} .ldp-solved-head .ldp-icon{width:16px;height:16px;flex:none;} .ldp-solved-body{padding:10px 12px 11px;} .ldp-solved-body + .ldp-solved-body{border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-solved-author-row{display:flex;align-items:center;flex-wrap:wrap;gap:7px;min-width:0;margin-bottom:7px;font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#666);} .ldp-solved-avatar{width:26px;height:26px;border-radius:50%;flex:none;} .ldp-solved-author{min-width:0;font-weight:700;color:var(--primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-solved-username{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-solved-floor{margin-left:auto;padding:0;border:0;background:transparent;flex:none;color:var(--success,#3f9625); cursor:pointer;font:inherit;font-weight:var(--ldp-reader-font-weight-base,400);} .ldp-solved-floor:hover{text-decoration:underline;} .ldp-solved-excerpt{max-width:75ch;font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;} .ldp-solved-excerpt.ldp-content{padding-inline:0;overflow-x:visible;} .ldp-solved-excerpt > :first-child{margin-top:0}.ldp-solved-excerpt > :last-child{margin-bottom:0} .ldp-solved-jump{margin-top:8px;padding:0;border:0;background:transparent;color:var(--success,#3f9625); cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;} .ldp-solved-jump:hover{text-decoration:underline;} .ldp-post.ldp-whisper{background:color-mix(in srgb,#7b61a8 7%,var(--secondary,#fff));} .ldp-post.ldp-post-voting-answer{padding-left:12px;} .ldp-post.ldp-system-post:not(.ldp-has-reactions) > .ldp-reactions, .ldp-post.ldp-system-post > .ldp-reactions .ldp-actions,.ldp-post-voting-answer > .ldp-reactions .ldp-like, .ldp-post-voting-answer > .ldp-reactions .ldp-replybtn{display:none;} .ldp-pv-layout{display:grid;grid-template-columns:46px minmax(0,1fr);gap:8px;align-items:start;} .ldp-pv-votes{display:flex;flex-direction:column;align-items:center;gap:3px;padding-top:4px;} .ldp-pv-vote{width:32px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--primary-medium,#777); cursor:pointer;line-height:1}.ldp-pv-vote .ldp-icon{width:20px;height:20px;} .ldp-pv-vote:hover,.ldp-pv-vote.on{background:var(--primary-very-low,#eee);color:var(--tertiary,#47855f)} .ldp-pv-vote:disabled{cursor:wait;opacity:.5}.ldp-pv-score{font-weight:750;font-variant-numeric:tabular-nums;} .ldp-pv-main{min-width:0}.ldp-pv-comments{margin:10px 8px 2px 54px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-pv-comment{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:7px;padding:9px 0;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-base,13px);} .ldp-pv-comment-avatar{width:26px;height:26px;border-radius:50%}.ldp-pv-comment-body{min-width:0}.ldp-pv-comment-meta{font-size:var(--ldp-font-sm,11px);opacity:.62;margin-bottom:2px} .ldp-pv-comment-vote,.ldp-pv-comments-toggle,.ldp-pv-comments-more,.ldp-pv-comment-submit{border:0;background:transparent;color:var(--tertiary,#47855f);cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:650;} .ldp-pv-comment-vote,.ldp-pv-comments-toggle{display:inline-flex;align-items:center;gap:4px;} .ldp-pv-comment-vote .ldp-icon,.ldp-pv-comments-toggle .ldp-icon{width:15px;height:15px;} .ldp-pv-comment-vote.on{color:#c65b24}.ldp-pv-comment-form{display:flex;gap:7px;padding:9px 0}.ldp-pv-comment-input{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--primary-low,#ccc);border-radius:6px;background:var(--secondary,#fff);color:inherit;font:inherit;} .ldp-post.ldp-has-reactions > .ldp-reactions .ldp-like{display:none}.ldp-reactions{display:flex;align-items:center;flex-wrap:wrap;gap:5px;width:fit-content;max-width:calc(100% - 16px);min-height:30px;height:auto;margin:12px 8px 0;overflow:visible;box-sizing:border-box;} .ldp-comments .ldp-post > .ldp-reactions{margin-top:0;} .ldp-reaction-summary{display:flex;flex:none;align-items:center;flex-wrap:wrap;gap:6px;width:fit-content;max-width:100%;min-height:30px;height:auto;padding:4px 9px;border-radius:999px;background:color-mix(in srgb,var(--primary,#222) 2%,transparent);box-sizing:border-box;} .ldp-reaction-summary:has(> .ldp-reaction-picker-anchor:only-child){min-height:20px;padding:0;background:transparent;} .ldp-reaction-picker-anchor{position:relative;display:inline-flex;flex:none;align-items:center;} .ldp-reaction-chip,.ldp-reaction-add{display:inline-flex;align-items:center;gap:3px;min-height:20px;padding:1px 2px;border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);} .ldp-reaction-chip:hover,.ldp-reaction-add:hover{opacity:.72}.ldp-reaction-chip.on{color:var(--tertiary,#47855f);font-weight:700;} .ldp-reaction-chip > span{display:inline-flex;align-items:center;font-size:14px;line-height:1;} .ldp-reaction-chip img.emoji,.ldp-reaction-chip img.emoji.only-emoji{width:14px;height:14px;margin:0;object-fit:contain;} .ldp-reaction-picker{position:absolute;left:0;bottom:calc(100% + 5px);z-index:3;display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:6px;width:max-content;max-width:min(320px,calc(100vw - 24px));min-height:30px;height:auto;padding:4px 9px;border:0;border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 4%,var(--secondary,#fff));white-space:normal;box-sizing:border-box} .ldp-event-card{margin:12px 8px;padding:14px;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff);box-shadow:0 2px 10px rgba(0,0,0,.04)} .ldp-event-title{margin:0 0 9px;font-size:var(--ldp-font-2xl,17px)}.ldp-event-grid{display:grid;gap:6px;font-size:var(--ldp-font-base,13px)}.ldp-event-meta{color:var(--primary-medium,#666)} .ldp-event-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.ldp-event-action,.ldp-event-ics{padding:6px 10px;border:1px solid var(--primary-low,#d5d5d5);border-radius:6px;background:var(--secondary,#fff);color:inherit;text-decoration:none;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px)} .ldp-event-action.on{border-color:var(--tertiary,#47855f);background:var(--tertiary,#47855f);color:#fff}.ldp-event-action:disabled{cursor:wait;opacity:.55} .ldp-children{margin-left:22px;padding-left:18px;position:relative; --ldp-nested-line-color:var(--ldp-reply-line-color,#47855f);--ldp-nested-line-width:var(--ldp-reply-line-width,1px); --ldp-nested-turn-rise:15px;--ldp-nested-corner-radius:var(--ldp-reply-line-radius,15px); --ldp-nested-branch-y:17px;--ldp-nested-parent-drop:34px; --ldp-nested-axis-left:3px;--ldp-nested-branch-width:15px; --ldp-nested-parent-head-width:15px;} .ldp-children-collapsed > .ldp-children{display:none;} .ldp-post.ldp-has-child-branches{overflow:visible;content-visibility:visible;} .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::before{content:"";position:absolute;left:var(--ldp-nested-axis-left); top:calc(0px - var(--ldp-nested-parent-drop) + var(--ldp-nested-turn-rise)); height:calc(var(--ldp-nested-parent-drop) - var(--ldp-nested-turn-rise));border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color); pointer-events:none;} .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready > .ldp-children::after{content:"";position:absolute;left:var(--ldp-nested-axis-left); top:calc(0px - var(--ldp-nested-parent-drop));width:calc(var(--ldp-nested-parent-head-width) - var(--ldp-nested-line-width)); height:var(--ldp-nested-turn-rise);border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color); border-top:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-top-left-radius:var(--ldp-nested-corner-radius); -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%); mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%); pointer-events:none;} .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::before, .ldp-post.ldp-has-child-branches.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children::after, .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::before, .ldp-post.ldp-nested-branches-ready.ldp-parent-preview-linked > .ldp-children > .ldp-post::after{display:none;} .ldp-children > .ldp-post{position:relative;overflow:visible;content-visibility:visible;} .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::before{content:"";position:absolute;left:calc(var(--ldp-nested-axis-left) - 18px); top:0;bottom:0;border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color); pointer-events:none;} .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post::after{content:"";position:absolute;left:calc(var(--ldp-nested-axis-left) - 18px); top:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise)); width:calc(var(--ldp-nested-branch-width) - var(--ldp-nested-line-width));height:var(--ldp-nested-turn-rise); border-left:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color); border-bottom:var(--ldp-nested-line-width) solid var(--ldp-nested-line-color);border-bottom-left-radius:var(--ldp-nested-corner-radius); -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%); mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-nested-corner-radius),calc(100% - 12px)),transparent 100%); pointer-events:none;} .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-last::before, .ldp-post.ldp-nested-branches-ready > .ldp-children > .ldp-post.ldp-nested-branch-single::before{bottom:auto;height:calc(var(--ldp-nested-branch-y) - var(--ldp-nested-turn-rise));} .ldp-nested-rail-toggle{display:none;position:absolute;z-index:4;width:20px;min-height:24px;padding:0;border:0; border-radius:999px;background:transparent;color:inherit;cursor:pointer;touch-action:manipulation;} .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-rail-toggle{display:block;} .ldp-nested-rail-toggle::before{content:"";position:absolute;inset:0 6px;border-radius:999px;background:transparent; transition:background-color .15s ease;} .ldp-nested-rail-toggle:focus-visible::before{ background:color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 18%,transparent);} .ldp-nested-rail-toggle:focus-visible{outline:none;} .ldp-nested-branch-toggle{display:none;position:absolute;z-index:4;overflow:visible;pointer-events:none;} .ldp-post.ldp-nested-branches-ready:not(.ldp-parent-preview-linked) > .ldp-children > .ldp-nested-branch-toggle{display:block;} .ldp-nested-branch-hit-path{fill:none;stroke:transparent;stroke-width:20px;stroke-linecap:round; stroke-linejoin:round;pointer-events:stroke;cursor:pointer;} .ldp-children > .ldp-nested-preview{padding:5px 0 7px;line-height:1.5;border-bottom:none; contain-intrinsic-size:auto 96px;} .ldp-children > .ldp-nested-preview > .ldp-content{font-size:calc(var(--ldp-post-font-size,14px) * var(--ldp-nested-content-scale));} .ldp-children > .ldp-nested-preview > :not(.ldp-content){zoom:var(--ldp-nested-component-scale);} .ldp-children > .ldp-nested-preview > .ldp-post-head{margin-bottom:0;} .ldp-children > .ldp-nested-preview .ldp-user, .ldp-children > .ldp-nested-preview .ldp-time{opacity:.5;} .ldp-floor.ldp-jump-self,.ldp-floor.ldp-jump-parent,.ldp-floor.ldp-jump-preview-self{border:none;cursor:pointer;margin-left:auto; padding:1px 5px;border-radius:5px;white-space:nowrap;line-height:1.35;} .ldp-floor.ldp-jump-parent{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 16%,transparent);} .ldp-floor.ldp-jump-parent[aria-busy="true"]{position:relative;color:transparent;cursor:wait;pointer-events:none;text-decoration:none;} .ldp-floor.ldp-jump-parent[aria-busy="true"]::after{content:"";position:absolute;left:50%;top:50%;box-sizing:border-box; width:11px;height:11px;margin:-5.5px 0 0 -5.5px;border:2px solid color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 28%,transparent); border-top-color:var(--ldp-floor-preview-color,#9a5b13);border-radius:50%;animation:ldp-spin .7s linear infinite;} .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);color:#2563b8;background:rgba(37,99,184,.08);} .ldp-floor.ldp-jump-preview-self{height:24px;display:inline-flex;align-items:center;line-height:1;font-size:var(--ldp-font-ui,12px);font-weight:var(--ldp-reader-font-weight-base,400);color:var(--ldp-floor-preview-color,#9a5b13);background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 16%,transparent);} .ldp-floor.ldp-body-floor{font-size:var(--ldp-font-ui,12px);padding:1px 5px;border-radius:5px; background:color-mix(in srgb,var(--primary,#222) 6%,transparent);} .ldp-floor.ldp-current-floor{margin-left:4px;color:var(--primary-medium,#777); font-weight:var(--ldp-reader-font-weight-base,400);opacity:.72;} .ldp-children > .ldp-nested-preview .ldp-floor.ldp-jump-self{ color:#2f7a68;background:rgba(47,122,104,.1);} .ldp-floor.ldp-jump-self:hover,.ldp-floor.ldp-jump-parent:hover, .ldp-floor.ldp-jump-preview-self:hover{filter:saturate(1.25);text-decoration:underline;} .ldp-floor-preview{position:relative;z-index:1;box-sizing:border-box;width:auto;max-width:none; max-height:min(var(--ldp-floor-preview-height,300px),calc(100vh - 24px));margin:8px 0 10px; border:1px solid var(--ldp-floor-preview-color,#9a5b13); border-radius:8px;background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff)); color:var(--primary,#222);box-shadow:0 3px 8px rgba(0,0,0,.1);padding:0;overflow:hidden; display:none;flex-direction:column;} .ldp-floor-preview.open{display:flex;} .ldp-floor-preview-close{position:absolute;right:8px;top:9px;z-index:3;border:none;background:transparent;color:inherit;cursor:pointer; width:auto;height:28px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:0 5px;} .ldp-floor-preview-close-hint{display:inline-flex;align-items:center;height:24px;line-height:1;font-size:var(--ldp-font-sm,11px);font-weight:500;color:var(--primary-medium,#777);white-space:nowrap;} .ldp-floor-preview-close:hover{background:transparent;color:var(--primary,#222);} .ldp-floor-preview-body{box-sizing:border-box;padding:10px 12px 12px;min-height:0; max-height:calc(var(--ldp-floor-preview-height,300px) - 52px);overflow-y:auto;} .ldp-floor-preview-post{font-size:var(--ldp-font-base,13px);line-height:1.55;max-width:100%;} .ldp-floor-preview-post .ldp-content{overflow-wrap:anywhere;} .ldp-floor-preview > .ldp-post-head{position:relative;z-index:2;isolation:isolate;flex:none; min-height:40px;box-sizing:border-box;margin:0;padding:5px 92px 5px 10px;align-items:center; gap:var(--ldp-nested-component-gap); background:color-mix(in srgb,var(--ldp-floor-preview-color,#9a5b13) 12%,var(--secondary,#fff)); box-shadow:inset 0 calc(0px - var(--ldp-divider-line-width,1px)) 0 var(--ldp-divider-line-color,#e5e5e5);} .ldp-floor-preview > .ldp-post-head > :not(.ldp-floor-preview-close){zoom:var(--ldp-nested-component-scale);} .ldp-floor-preview > .ldp-post-head > .ldp-floor-preview-close{top:50%;transform:translateY(-50%);} .ldp-floor-preview-link{display:none;position:relative;height:0;margin:0;overflow:visible; pointer-events:none;z-index:2; --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f);--ldp-parent-link-width:var(--ldp-reply-line-width,1px); --ldp-parent-turn-rise:15px;--ldp-parent-corner-radius:var(--ldp-reply-line-radius,15px); --ldp-parent-axis-left:-15px;--ldp-parent-preview-rail-width:15px; --ldp-parent-child-y:60px;--ldp-parent-child-branch-width:15px; --ldp-parent-axis-top:12px;--ldp-parent-preview-y:12px;} .ldp-floor-preview.open + .ldp-floor-preview-link, .ldp-floor-preview-link.open{display:block;} .ldp-floor-preview-link::before{content:"";position:absolute;left:var(--ldp-parent-axis-left); top:calc(var(--ldp-parent-axis-top) + var(--ldp-parent-turn-rise)); width:calc(var(--ldp-parent-child-branch-width) - var(--ldp-parent-link-width)); height:calc(var(--ldp-parent-child-y) - var(--ldp-parent-axis-top) - var(--ldp-parent-turn-rise)); border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-bottom:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color); border-bottom-left-radius:var(--ldp-parent-corner-radius); -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%); mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);} .ldp-floor-preview-link::after{content:"";position:absolute;left:var(--ldp-parent-axis-left); top:var(--ldp-parent-preview-y);width:calc(var(--ldp-parent-preview-rail-width) - var(--ldp-parent-link-width)); height:var(--ldp-parent-turn-rise);border-left:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color); border-top:var(--ldp-parent-link-width) solid var(--ldp-parent-link-color);border-top-left-radius:var(--ldp-parent-corner-radius); -webkit-mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%); mask-image:linear-gradient(to right,#000 0,#000 max(var(--ldp-parent-corner-radius),calc(100% - 12px)),transparent 100%);} .ldp-floor-preview-link.ldp-floor-preview-link-grouped::before, .ldp-floor-preview-link.ldp-floor-preview-link-grouped::after{display:none;} .ldp-post.ldp-parent-preview-linked{position:relative;} .ldp-thread-group-svg{position:absolute;z-index:1;overflow:visible;pointer-events:none; color:var(--ldp-parent-link-color); --ldp-parent-link-color:var(--ldp-reply-line-color,#47855f); --ldp-parent-link-width:var(--ldp-reply-line-width,1px);} .ldp-thread-group-path{fill:none;stroke:var(--ldp-parent-link-color);stroke-width:var(--ldp-parent-link-width); stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke;} .ldp-floor-preview-post .ldp-actions, .ldp-floor-preview-post .ldp-collapse-replies, .ldp-floor-preview-post .ldp-children, .ldp-floor-preview-post .ldp-nested-toggle{display:none!important;} .ldp-floor-preview-tip{height:100%;display:flex;align-items:center;justify-content:center; color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);} .ldp-actions{display:inline-flex;gap:2px;margin:0 0 0 2px;font-size:var(--ldp-font-ui,12px);align-items:center; flex:none;opacity:1;pointer-events:auto;} .ldp-context-actions-slot{display:inline-flex;align-items:center;gap:2px;flex:none;height:20px; width:0;min-width:0;overflow:hidden;} .ldp-reactions:hover .ldp-context-actions-slot, .ldp-reactions:focus-within .ldp-context-actions-slot, .ldp-post.ldp-actions-active > .ldp-reactions .ldp-context-actions-slot{ width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;} .ldp-context-actions-slot:empty{pointer-events:none;} .ldp-actions .ldp-btn,.ldp-reaction-add{min-width:20px;height:20px;justify-content:center;padding:2px;gap:2px;} .ldp-actions .ldp-btn:not(.ldp-like),.ldp-reaction-add{width:20px;} .ldp-actions .ldp-replybtn span{display:none;} .ldp-topic-bookmark.on,.ldp-post-bookmark.on{color:var(--tertiary,#47855f);opacity:1;} .ldp-topic-bookmark.on .ldp-icon-bookmark, .ldp-post-bookmark.on .ldp-icon-bookmark{fill:currentColor;} .ldp-like-count{display:inline;font-size:var(--ldp-font-sm,11px);line-height:1;} .ldp-btn{cursor:pointer;border:none;background:transparent;color:inherit; opacity:.7;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;} .ldp-btn:hover{opacity:1;} .ldp-btn:disabled{cursor:default;opacity:.4;} .ldp-like.liked{color:var(--love,#e25822);opacity:1;font-weight:600;} .ldp-like.liked .ldp-icon-heart{fill:currentColor;} .ldp-actions .ldp-context-action{opacity:0;visibility:hidden;pointer-events:none; transition:opacity .12s ease,visibility 0s linear .12s;} .ldp-reactions:hover .ldp-actions .ldp-context-action, .ldp-reactions:focus-within .ldp-actions .ldp-context-action, .ldp-post.ldp-actions-active > .ldp-reactions .ldp-actions .ldp-context-action{ opacity:.7;visibility:visible;pointer-events:auto;transition-delay:0s;} .ldp-actions .ldp-context-action:hover{opacity:1;} @media (hover:none){ .ldp-context-actions-slot{width:calc(var(--ldp-context-action-count,1) * 22px - 2px);overflow:visible;} .ldp-actions .ldp-context-action{opacity:.7;visibility:visible;pointer-events:auto;transition:none;} } .ldp-topic-footer-actions{display:flex;align-items:center;flex-wrap:nowrap;width:max-content;max-width:100%; gap:2px;margin:10px 0 2px; padding:8px 6px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);font-size:var(--ldp-font-ui,12px);} .ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification{box-sizing:border-box;width:34px;height:34px; min-height:0;margin:0;display:inline-flex;align-items:center;justify-content:center;padding:6px; border:0;border-radius:6px;background:transparent;color:inherit; font:inherit;line-height:1;vertical-align:top;text-decoration:none;white-space:nowrap;} .ldp-topic-footer-actions > * > .ldp-icon{display:block;} .ldp-topic-footer-button > span,.ldp-topic-footer-link > span{display:none;} .ldp-topic-footer-button,.ldp-topic-footer-link{cursor:pointer;} .ldp-topic-footer-button:hover,.ldp-topic-footer-link:hover,.ldp-topic-notification:hover{ background:var(--primary-very-low,#f4f4f4);color:inherit;} .ldp-topic-footer-button:disabled{cursor:default;opacity:.45;} .ldp-topic-footer-button.ldp-topic-reply{background:transparent;color:inherit;} .ldp-topic-footer-button.ldp-topic-reply:hover{background:var(--primary-very-low,#f4f4f4);} .ldp-topic-footer-button.ldp-topic-bookmark.on{background:color-mix(in srgb,var(--tertiary-low,#dceee2) 72%,var(--secondary,#fff));} .ldp-topic-footer-button.ldp-topic-shared-issue{width:auto;min-width:34px;gap:4px;padding-inline:8px;} .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{ display:block;font-weight:500;letter-spacing:0;} .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{ display:block;margin-left:-1px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px); font-variant-numeric:tabular-nums;} .ldp-topic-footer-button.ldp-topic-shared-issue.on{opacity:1;} .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-icon, .ldp-topic-footer-button.ldp-topic-shared-issue.on > .ldp-topic-shared-issue-label{ color:var(--tertiary,#47855f);} .ldp-topic-footer-separator{flex:none;width:1px;height:14px;padding:0 3px; background:linear-gradient(var(--ldp-divider-line-color,#e5e5e5),var(--ldp-divider-line-color,#e5e5e5)) center/var(--ldp-divider-line-width,1px) 14px no-repeat; transform:translateY(1px);} .ldp-topic-notification{position:relative;padding:6px;cursor:pointer;overflow:hidden;} .ldp-topic-notification select{position:absolute;inset:0;width:100%;height:100%;min-width:0; border:0;opacity:0;cursor:pointer;outline:none;} @supports (appearance:base-select){ .ldp-topic-notification .ldp-topic-notification-select{--ldp-select-picker-width:132px; --ldp-select-picker-max-width:min(132px,calc(100vw - 24px));background:transparent;color:transparent; box-shadow:none;opacity:1;} .ldp-topic-notification-select::picker-icon{display:none;} .ldp-topic-notification-select::picker(select){color:var(--primary,#222);} } .ldp-topic-notification.busy{opacity:.55;} .ldp-floor-preview .ldp-topic-footer-actions{display:none!important;} .ldp-boost-list{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px;} .ldp-boost-list.ldp-boost-pending{min-height:26px;contain:layout paint;pointer-events:none;} .ldp-boost-list.ldp-boost-pending::before{content:"";width:64px;height:22px;border-radius:999px; background:var(--primary-very-low,#f2f4f6);} .ldp-boost-bubble{position:relative;display:inline-flex;align-items:center;gap:5px;max-width:100%; padding:3px 8px 3px 3px;border-radius:999px;background:var(--primary-very-low,#f2f4f6); color:var(--primary,#222);font-size:var(--ldp-font-ui,12px);line-height:1.35;} .ldp-floor-preview .ldp-boost-bubble{opacity:.82;} .ldp-boost-avatar-link{display:inline-flex;line-height:0;flex:none;} .ldp-boost-avatar{width:20px;height:20px;border-radius:50%;} .ldp-boost-fallback-icon{width:14px;height:14px;margin-left:3px;opacity:.65;} .ldp-boost-identities{display:inline-flex;align-items:center;gap:3px;flex:none;} .ldp-boost-identity{display:inline-flex;align-items:center;gap:2px;min-height:16px;box-sizing:border-box; padding:1px 5px 1px 3px;border:1px solid color-mix(in srgb,var(--tertiary,#47855f) 34%,transparent); border-radius:999px;background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff)); color:var(--tertiary,#47855f);font-size:var(--ldp-font-xs,10px);font-weight:700;line-height:1;white-space:nowrap;} .ldp-boost-identity .ldp-icon{width:10px;height:10px;stroke-width:2.35;} .ldp-boost-identity-me{border-color:color-mix(in srgb,var(--success,#3ea66b) 40%,transparent); background:color-mix(in srgb,var(--success,#3ea66b) 12%,var(--secondary,#fff));color:var(--success,#3ea66b);} .ldp-boost-identity-admin,.ldp-boost-identity-moderator{ border-color:color-mix(in srgb,var(--primary,#222) 28%,transparent); background:color-mix(in srgb,var(--primary,#222) 7%,var(--secondary,#fff));color:var(--primary-high,#333);} .ldp-boost-cooked{display:inline-flex;align-items:center;min-width:0;overflow:hidden;} .ldp-boost-cooked p{margin:0;display:inline-flex;align-items:center;gap:4px;min-width:0;} .ldp-boost-cooked img.emoji,.ldp-boost-cooked img.emoji.only-emoji{width:16px;height:16px;margin:0;} .ldp-boost-item-action{display:none;align-items:center;justify-content:center;flex:none; width:18px;height:18px;margin-left:1px;padding:2px;border:0;border-radius:50%;cursor:pointer; background:transparent;color:var(--primary-medium,#777);opacity:0;visibility:hidden;pointer-events:none; transition:opacity .16s ease,color .16s ease,background-color .16s ease;} .ldp-boost-delete-action{color:var(--danger,#c94747);} .ldp-boost-delete-action .ldp-icon,.ldp-boost-report-action .ldp-icon{ width:15px;height:15px;stroke-width:2.25;} @keyframes ldp-boost-item-action-hover-ready{ to{width:18px;height:18px;margin-left:1px;padding:2px;visibility:visible;pointer-events:auto;}} .ldp-boost-bubble:hover > .ldp-boost-item-action{ display:inline-flex;width:0;height:0;margin-left:-5px;padding:0;overflow:hidden;opacity:.72; animation:ldp-boost-item-action-hover-ready 1ms linear .5s forwards;} .ldp-boost-bubble:focus-within > .ldp-boost-item-action{ display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible; opacity:.72;visibility:visible;pointer-events:auto;animation:none;} .ldp-boost-item-action:hover,.ldp-boost-item-action:focus-visible{ opacity:1;background:var(--primary-low,#d7dde3);color:var(--primary,#222);outline:none;} .ldp-boost-delete-action:hover,.ldp-boost-delete-action:focus-visible{ background:color-mix(in srgb,var(--danger,#c94747) 14%,transparent);color:var(--danger,#c94747);} .ldp-boost-item-action:disabled{cursor:default;opacity:.35;} .ldp-boost-copy-action{position:absolute;left:50%;bottom:calc(100% - 2px);z-index:2; margin:0;padding:6px 9px;border:0;cursor:pointer;font-family:inherit; transform:translateX(-50%);opacity:0;visibility:hidden;pointer-events:none;} .ldp-boost-copy-action.ldp-action-surface{ border-color:color-mix(in srgb,var(--tertiary,#47855f) 38%,var(--primary-low,#ddd)); color:var(--tertiary,#47855f);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.25;} .ldp-boost-bubble:hover > .ldp-boost-copy-action, .ldp-boost-bubble:focus-within > .ldp-boost-copy-action{ opacity:1;visibility:visible;pointer-events:auto;} .ldp-boost-copy-action:hover,.ldp-boost-copy-action:focus-visible{ border-color:var(--tertiary,#47855f); background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff)); color:var(--tertiary,#47855f);outline:none;} .ldp-boost-bubble:has(> .ldp-boost-item-action:hover) > .ldp-boost-copy-action, .ldp-boost-bubble:has(> .ldp-boost-item-action:focus-visible) > .ldp-boost-copy-action{ opacity:0;visibility:hidden;pointer-events:none;} @media (hover:none){ .ldp-boost-item-action,.ldp-boost-bubble:hover > .ldp-boost-item-action{ display:inline-flex;width:18px;height:18px;margin-left:1px;padding:2px;overflow:visible; opacity:.72;visibility:visible;pointer-events:auto;animation:none;transition:none;} } html.ldp-reader-open body > [data-ldp-reader-composer-root]{position:relative!important; z-index:2147483604!important;overflow:visible!important;} html.ldp-reader-open [data-ldp-reader-composer-overflow]{overflow-x:clip!important;} html.ldp-reader-open #reply-control:not(.closed), html.ldp-reader-open .composer-popup:not(.closed){visibility:visible!important; z-index:2147483605!important;} html.ldp-reader-open #reply-control:not(.closed):not([data-ldp-reader-composer-positioned="1"]){visibility:hidden!important;} html.ldp-reader-open :is(#d-menu-portals,.fk-d-menu,.emoji-picker,.composer-popup,.dialog-holder,.d-modal){visibility:visible!important; z-index:2147483610!important;} html.ldp-reader-open #reply-control[data-ldp-reader-top-layer="composer"][popover]{margin:0!important;} html.ldp-reader-open #reply-control:not(.closed){position:fixed!important; left:var(--ldp-composer-left,50%)!important;top:var(--ldp-composer-top,25vh)!important; right:auto!important;bottom:auto!important; transform:var(--ldp-composer-transform,translateX(-50%))!important; width:var(--ldp-composer-width,min(1200px,calc(100vw - 32px)))!important; height:var(--ldp-composer-height,70vh)!important; min-width:min(520px,calc(100vw - 32px))!important;min-height:min(300px,calc(100vh - 32px))!important; max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important; box-sizing:border-box!important;border:0!important;outline:0!important;border-radius:10px!important; color:var(--ldp-interface-font-color,var(--primary,#222))!important; font-family:var(--ldp-interface-font-family,inherit)!important; font-size:var(--ldp-font-md,14px)!important;font-weight:var(--ldp-interface-font-weight,400);will-change:translate; box-shadow:0 18px 54px rgba(0,0,0,.28)!important;overflow:hidden!important; animation:none!important;transition:none!important;} html.ldp-reader-open #reply-control:not(.closed).fullscreen{inset:16px!important; width:auto!important;height:auto!important;min-width:0!important;min-height:0!important; max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 32px)!important; transform:none!important;border-radius:10px!important;} html.ldp-reader-open #reply-control .reply-area{min-width:0!important;overflow:hidden!important;} html.ldp-reader-open #reply-control .toggle-minimize{display:none!important;} html.ldp-reader-open #reply-control .reply-to .presence-users{align-items:center!important; flex:0 0 auto!important;min-width:max-content!important;overflow:visible!important;white-space:nowrap!important;} html.ldp-reader-open #reply-control .reply-to .presence-avatars{align-items:center!important; flex:0 0 auto!important;flex-wrap:nowrap!important;overflow:visible!important;} html.ldp-reader-open #reply-control .reply-to .presence-avatars > *{display:inline-flex!important; flex:0 0 auto!important;line-height:0!important;} html.ldp-reader-open #reply-control .reply-to .presence-avatars img.avatar{display:block!important;flex:none!important;} html.ldp-reader-open #reply-control .reply-to span.presence-text{flex:0 0 auto!important; min-width:max-content!important;overflow:visible!important;} html.ldp-reader-open #reply-control .submit-panel{padding-bottom:18px!important; box-sizing:border-box!important;} html.ldp-reader-open #reply-control .d-editor-container{min-width:0!important;} html.ldp-reader-open #reply-control .d-editor-textarea-wrapper, html.ldp-reader-open #reply-control .d-editor-preview-wrapper, html.ldp-reader-open #reply-control .composer-preview, html.ldp-reader-open #reply-control .preview-area{min-width:0!important; width:auto!important;max-width:none!important;overflow:auto!important;} html.ldp-reader-open #reply-control .d-editor-input, html.ldp-reader-open #reply-control textarea, html.ldp-reader-open #reply-control .d-editor-preview, html.ldp-reader-open #reply-control .ProseMirror{color:var(--ldp-composer-font-color,var(--primary,#222))!important; font-family:var(--ldp-composer-font-family,inherit)!important; font-size:var(--ldp-composer-font-size,80%)!important;font-weight:var(--ldp-composer-font-weight,400)!important; line-height:1.5!important;} html.ldp-reader-open #reply-control :is(.d-editor-preview,.composer-preview,.preview-area) aside.quote blockquote img:not(.emoji){display:block!important;width:auto!important;height:auto!important; max-width:70%!important;max-height:360px!important;margin:8px auto!important;object-fit:contain;} html.ldp-reader-open #user-card, html.ldp-reader-open #group-card, html.ldp-reader-open .user-card, html.ldp-reader-open .group-card, html.ldp-reader-open .user-card-container{visibility:visible!important; z-index:2147483607!important;--ldp-native-user-card-scale:.8;color:var(--ldp-interface-font-color,inherit); font-family:var(--ldp-interface-font-family,inherit);font-weight:var(--ldp-interface-font-weight,400);} html.ldp-reader-open #user-card, html.ldp-reader-open #group-card, html.ldp-reader-open .user-card{zoom:var(--ldp-native-user-card-scale)!important; max-width:none!important;} .ldp-composer-window-chrome{position:fixed;z-index:2147483606;left:0;top:0; width:var(--ldp-composer-chrome-width,0);height:var(--ldp-composer-chrome-height,0); transform:none;box-sizing:border-box;pointer-events:none;will-change:transform;} .ldp-composer-window-chrome[data-ldp-reader-top-layer="chrome"][popover]{right:auto!important;bottom:auto!important; margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;} html.ldp-reader-open [data-ldp-reader-top-layer="portal"][popover]{ margin:0!important;padding:0!important;border:0!important;background:transparent!important;overflow:visible!important;} html.ldp-reader-open [data-ldp-reader-top-layer][popover]::backdrop{background:transparent!important;pointer-events:none!important;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"]{ color:var(--ldp-interface-font-color,var(--primary,#222));font-family:var(--ldp-interface-font-family,inherit); font-size:var(--ldp-font-base,13px);font-weight:var(--ldp-interface-font-weight,400);} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .fk-d-menu__inner-content{ box-sizing:border-box!important;border:1px solid var(--primary-low,#d7d7d7)!important;border-radius:8px!important; background:var(--secondary,#fff)!important;color:inherit!important;box-shadow:0 8px 24px rgba(0,0,0,.18)!important; overscroll-behavior:contain;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu{ min-width:200px;gap:2px!important;box-sizing:border-box;margin:0!important;padding:5px!important;list-style:none;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a), html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn{ display:flex;box-sizing:border-box;width:100%;min-height:36px;align-items:center;gap:10px;padding:6px 8px!important; border:0!important;border-radius:6px!important;background:transparent!important;color:var(--primary,#222)!important; box-shadow:none!important;font:inherit!important;font-size:var(--ldp-font-base,13px)!important;font-weight:550!important; line-height:1.35;text-align:left;text-decoration:none!important;cursor:pointer;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible), html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible){ background:var(--primary-very-low,#f2f2f2)!important;color:var(--primary,#222)!important;outline:none!important;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(.active,[aria-current="true"],[aria-checked="true"]), html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(.active,[aria-current="true"],[aria-checked="true"]){ background:color-mix(in srgb,var(--tertiary-low,#dceee2) 68%,var(--secondary,#fff))!important; color:var(--tertiary,#47855f)!important;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:disabled,[aria-disabled="true"]), html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:disabled,[aria-disabled="true"]){ background:transparent!important;color:var(--primary-medium,#777)!important;opacity:.48;cursor:default;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .d-icon{ width:18px;height:18px;flex:none;color:var(--primary-medium,#777);} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item > :is(.btn,a):is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon, html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__item .btn:is(:hover,:focus-visible,.active,[aria-current="true"],[aria-checked="true"]) .d-icon{ color:inherit;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__divider{ margin:3px 4px!important;border-color:var(--ldp-divider-line-color,var(--primary-low,#ddd))!important;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] .dropdown-menu__subheader{ padding:7px 8px 3px!important;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);font-weight:700;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *){ scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff)) transparent;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar{ width:9px;height:9px;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-button{ display:none;width:0;height:0;} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-track{ border-radius:999px;background:linear-gradient(90deg,transparent 0 calc(50% - .75px), color-mix(in srgb,var(--ldp-reply-line-color,#47855f) 28%,transparent) calc(50% - .75px) calc(50% + .75px), transparent calc(50% + .75px) 100%);} html.ldp-reader-open .fk-d-menu[data-ldp-reader-top-layer="portal"] :is(.fk-d-menu__inner-content,.fk-d-menu__inner-content *)::-webkit-scrollbar-thumb{ border-radius:5px;background:color-mix(in srgb,var(--tertiary,#47855f) 42%,var(--secondary,#fff));} .ldp-composer-drag-handle{position:absolute;left:50%;top:-32px;z-index:2; width:140px;height:48px;transform:translateX(-50%);box-sizing:border-box;padding:0; border:0;background:transparent;box-shadow:none;color:transparent; cursor:grab;pointer-events:auto;touch-action:none;} .ldp-composer-drag-handle::before{display:none;} .ldp-composer-window-chrome.is-interacting .ldp-composer-drag-handle{cursor:grabbing;} .ldp-composer-resize-handle{position:absolute;z-index:1;display:block;pointer-events:auto;touch-action:none;} .ldp-composer-resize-handle[data-resize="n"]{left:16px;right:16px;top:-6px;height:12px;cursor:n-resize;} .ldp-composer-resize-handle[data-resize="s"]{left:16px;right:16px;bottom:-6px;height:12px;cursor:s-resize;} .ldp-composer-resize-handle[data-resize="e"]{top:16px;right:-6px;bottom:16px;width:12px;cursor:e-resize;} .ldp-composer-resize-handle[data-resize="w"]{top:16px;left:-6px;bottom:16px;width:12px;cursor:w-resize;} .ldp-composer-resize-handle[data-resize="ne"]{top:-7px;right:-7px;width:20px;height:20px;cursor:ne-resize;} .ldp-composer-resize-handle[data-resize="nw"]{top:-7px;left:-7px;width:20px;height:20px;cursor:nw-resize;} .ldp-composer-resize-handle[data-resize="se"]{right:-8px;bottom:-8px;width:28px;height:28px;padding:0; border:0;border-radius:0 0 8px 0;background:transparent;color:var(--primary-medium,#777);cursor:se-resize;} .ldp-composer-resize-handle[data-resize="se"]::before, .ldp-composer-resize-handle[data-resize="se"]::after{content:"";position:absolute;border-right:2px solid currentColor; border-bottom:2px solid currentColor;border-radius:0 0 2px 0;opacity:.58;transition:opacity .15s ease,color .15s ease;} .ldp-composer-resize-handle[data-resize="se"]::before{right:5px;bottom:5px;width:11px;height:11px;} .ldp-composer-resize-handle[data-resize="se"]::after{right:9px;bottom:9px;width:5px;height:5px;} .ldp-composer-resize-handle[data-resize="se"]:hover, .ldp-composer-resize-handle[data-resize="se"]:focus-visible{color:var(--tertiary,#47855f);} .ldp-composer-resize-handle[data-resize="se"]:focus-visible{outline:2px solid color-mix(in srgb,var(--tertiary,#47855f) 62%,transparent); outline-offset:-3px;} .ldp-composer-resize-handle[data-resize="se"]:hover::before, .ldp-composer-resize-handle[data-resize="se"]:hover::after, .ldp-composer-resize-handle[data-resize="se"]:focus-visible::before, .ldp-composer-resize-handle[data-resize="se"]:focus-visible::after{opacity:1;} .ldp-composer-resize-handle[data-resize="sw"]{left:-7px;bottom:-7px;width:20px;height:20px;cursor:sw-resize;} html.ldp-composer-window-interacting,html.ldp-composer-window-interacting *{user-select:none!important;} @media (max-width:760px){ html.ldp-reader-open #reply-control:not(.closed){left:6px!important;right:6px!important; top:auto!important;bottom:0!important;width:auto!important;height:50vh!important; min-width:0!important;min-height:0!important;max-width:none!important;max-height:calc(100vh - 12px)!important; transform:none!important;border-radius:10px 10px 0 0!important;} .ldp-composer-window-chrome{display:none!important;} html.ldp-reader-open #reply-control .d-editor-preview-wrapper, html.ldp-reader-open #reply-control .composer-preview, html.ldp-reader-open #reply-control .preview-area{min-height:140px!important;} } .ldp-user-card-fallback{position:fixed;z-index:2147483608;display:none;width:min(360px,calc(100vw - 24px)); max-height:min(480px,calc(100vh - 24px));overflow:auto;overscroll-behavior:contain;box-sizing:border-box; border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff); color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);padding:12px;} .ldp-user-card-fallback.open{display:block;} .ldp-user-card-fallback:has(.ldp-user-card-badges){max-height:min(520px,calc(100vh - 24px));} .ldp-user-card-background{display:block;width:calc(100% + 24px);height:120px;margin:-12px -12px 12px; overflow:hidden;padding:0;border:0;border-radius:9px 9px 0 0;background:var(--primary-very-low,#f7f7f7); cursor:zoom-in;} .ldp-user-info-cover img,.ldp-user-card-background img{display:block;width:100%;height:100%;object-fit:cover;} .ldp-user-card-background:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:-3px;} .ldp-user-card-head{display:flex;gap:10px;align-items:center;margin-bottom:8px;padding-right:44px;} .ldp-user-card-identity{min-width:0;} .ldp-user-card-name-row{display:flex;align-items:center;gap:6px;min-width:0;} .ldp-user-card-level{flex:none;padding:2px 7px;border-radius:999px; background:color-mix(in srgb,var(--tertiary,#08c) 12%,var(--secondary,#fff)); color:var(--tertiary,#08c);font-size:var(--ldp-font-sm,11px);font-weight:700;line-height:1.45;} .ldp-user-card-avatar-trigger{display:inline-flex;width:48px;height:48px;flex:none;align-items:center;justify-content:center; padding:0;border:0;border-radius:50%;background:transparent;box-shadow:0 0 0 0 transparent;cursor:zoom-in;} .ldp-user-card-avatar-trigger .ldp-avatar-flair{right:-4px;bottom:-3px;width:19px;height:19px;border-width:2px;} .ldp-user-card-avatar-trigger:hover{box-shadow:0 0 0 2px color-mix(in srgb,var(--tertiary,#08c) 38%,transparent);} .ldp-user-card-avatar-trigger:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:3px;} .ldp-user-card-avatar{width:48px;height:48px;border-radius:50%;flex:none;object-fit:cover;} .ldp-avatar-viewer{position:fixed;z-index:2147483609;display:flex;flex-direction:column; width:min(320px,calc(100vw - 20px));height:min(358px,calc(100vh - 20px));max-height:calc(100vh - 20px); box-sizing:border-box;overflow:hidden;padding:8px;border-radius:12px;background:var(--secondary,#fff); color:var(--primary,#222);box-shadow:0 8px 24px rgba(0,0,0,.28);} .ldp-avatar-viewer.is-background{width:min(560px,calc(100vw - 20px));height:min(360px,calc(100vh - 20px));} .ldp-avatar-viewer.is-image{width:min(480px,calc(100vw - 20px));height:min(480px,calc(100vh - 20px)); border:1px solid rgba(255,255,255,.14);background:rgba(7,9,12,.92);color:#fff;box-shadow:0 18px 54px rgba(0,0,0,.48);} .ldp-avatar-viewer-toolbar{display:flex;min-width:0;flex:none;align-items:center;justify-content:flex-end;gap:4px;margin-bottom:6px;} .ldp-avatar-viewer-progress{display:flex;min-width:0;flex:1;align-items:center;gap:7px;margin-right:4px; color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;} .ldp-avatar-viewer-progress-track{height:4px;min-width:36px;flex:1;overflow:hidden;border-radius:999px; background:color-mix(in srgb,var(--primary-low,#ddd) 82%,var(--secondary,#fff));} .ldp-avatar-viewer-progress-fill{display:block;width:var(--ldp-avatar-progress,0%);height:100%;border-radius:inherit; background:var(--tertiary,#47855f);transition:width .16s ease-out;} .ldp-avatar-viewer-progress-value{min-width:6.5em;flex:none;text-align:right;white-space:nowrap;} .ldp-avatar-viewer-progress.is-indeterminate .ldp-avatar-viewer-progress-fill{width:34%; animation:ldp-avatar-viewer-progress 1.1s ease-in-out infinite alternate;} @keyframes ldp-avatar-viewer-progress{from{transform:translateX(-15%);}to{transform:translateX(210%);}} .ldp-avatar-viewer-stage{position:relative;display:grid;width:100%;min-width:0;min-height:0;flex:1; place-items:center;overflow:hidden;border-radius:8px;background:var(--secondary,#fff);} .ldp-avatar-viewer-image{display:block;width:100%;height:100%;max-width:100%;max-height:100%; min-width:0;min-height:0;border-radius:6px;object-fit:contain;} .ldp-avatar-viewer-stage > .ldp-avatar-flair{right:12px;bottom:12px;z-index:2;width:56px;height:56px; border-width:3px;box-shadow:0 3px 12px rgba(0,0,0,.18);} .ldp-avatar-viewer .ldp-lb-btn{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff); color:var(--primary-high,#444);} .ldp-avatar-viewer .ldp-lb-btn:hover{background:var(--primary-very-low,#f7f7f7);} .ldp-avatar-viewer .ldp-lb-btn:focus-visible{outline-color:var(--tertiary,#08c);} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-toolbar{margin-bottom:8px;} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-stage{overflow:hidden;background:rgba(10,12,16,.5);} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image{transform:translate(var(--ldp-avatar-pan-x,0),var(--ldp-avatar-pan-y,0)) scale(var(--ldp-avatar-scale,1)); transform-origin:center;will-change:transform;}.ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-zoomed{cursor:grab;} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-image.is-dragging{cursor:grabbing;} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress,.ldp-avatar-viewer.is-image .ldp-avatar-viewer-status{color:rgba(255,255,255,.72);} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-track{background:rgba(255,255,255,.12);} .ldp-avatar-viewer.is-image .ldp-avatar-viewer-progress-fill{background:#8cc7ff;} .ldp-avatar-viewer.is-image .ldp-lb-btn{border:0;background:rgba(255,255,255,.1);color:#fff;} .ldp-avatar-viewer.is-image .ldp-lb-btn:hover:not(:disabled){background:rgba(255,255,255,.2);} .ldp-avatar-viewer.is-image .ldp-lb-btn:focus-visible{outline-color:#8cc7ff;} .ldp-avatar-viewer-selection{display:inline-flex;min-width:0;align-items:center;gap:7px;margin-right:auto;padding:5px 8px; border-radius:7px;background:rgba(255,255,255,.1);color:#fff;font-size:var(--ldp-font-ui,12px);cursor:pointer;} .ldp-avatar-viewer-selection input{width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;} .ldp-avatar-viewer-selection span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-avatar-viewer-nav{position:absolute;z-index:3;top:50%;display:grid;width:42px;height:54px;place-items:center; transform:translateY(-50%);border:0;border-radius:8px;background:rgba(0,0,0,.48);color:#fff;cursor:pointer;} .ldp-avatar-viewer-nav:hover{background:rgba(0,0,0,.68);}.ldp-avatar-viewer-nav:disabled{opacity:.3;cursor:default;} .ldp-avatar-viewer-nav .ldp-icon{width:22px;height:22px;}.ldp-avatar-viewer-prev{left:8px;} .ldp-avatar-viewer-next{right:8px;}.ldp-avatar-viewer-prev .ldp-icon{transform:scaleX(-1);} .ldp-avatar-viewer-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%); color:var(--primary-medium,#777);font-size:var(--ldp-font-base,13px);} .ldp-user-card-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-size:var(--ldp-font-lg,15px);font-weight:700;line-height:1.25;} .ldp-user-card-username{font-size:var(--ldp-font-ui,12px);color:var(--primary-medium,#777);line-height:1.3;} .ldp-user-card-title{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);margin-top:2px;} .ldp-user-profile-facts.is-card{gap:5px 12px;margin:-2px 0 8px; font-size:var(--ldp-font-sm,11px);line-height:1.45;} .ldp-user-profile-facts.is-card .is-wide{max-width:100%;} .ldp-user-profile-facts.is-card .is-wide .ldp-user-profile-fact-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-user-card-groups{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:7px;margin:6px 0; color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.45;} .ldp-user-card-groups > span{padding-top:3px;white-space:nowrap;} .ldp-user-card-groups > div{display:flex;min-width:0;flex-wrap:wrap;gap:4px;} .ldp-user-card-groups a{display:inline-flex;max-width:100%;padding:2px 7px;border:1px solid var(--primary-low,#ddd); border-radius:999px;background:color-mix(in srgb,var(--tertiary,#08c) 7%,var(--secondary,#fff)); color:var(--tertiary,#08c);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-user-card-groups a:hover,.ldp-user-card-groups a:focus-visible{border-color:var(--tertiary,#08c);outline:none; background:color-mix(in srgb,var(--tertiary,#08c) 14%,var(--secondary,#fff));} .ldp-user-card-follow-stats{display:flex;align-items:center;gap:18px;margin:7px 0;} .ldp-user-card-follow-stat{display:inline-flex;min-width:0;align-items:baseline;gap:5px;padding:2px 0;border:0; background:transparent;color:inherit;text-align:left;cursor:pointer;} .ldp-user-card-follow-stat:hover,.ldp-user-card-follow-stat[aria-expanded="true"]{color:var(--tertiary,#08c);} .ldp-user-card-follow-stat.is-readonly,.ldp-user-card-follow-stat.is-readonly:hover{color:inherit;cursor:default;} .ldp-user-card-follow-stat:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:2px;border-radius:3px;} .ldp-user-card-follow-stat:disabled{opacity:.48;cursor:not-allowed;} .ldp-user-card-follow-stat strong{font-size:var(--ldp-font-base,13px);font-variant-numeric:tabular-nums;} .ldp-user-card-follow-stat span{overflow:hidden;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px); text-overflow:ellipsis;white-space:nowrap;} .ldp-user-card-follow-panel{position:fixed;z-index:2147483610;display:grid;width:min(320px,calc(100vw - 20px)); max-height:min(420px,calc(100vh - 20px));box-sizing:border-box;gap:6px;margin:0;padding:9px; grid-template-rows:auto auto auto auto minmax(0,1fr) auto; overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:10px;background:var(--secondary,#fff); color:var(--primary,#222);box-shadow:0 18px 44px rgba(0,0,0,.22);} .ldp-user-card-follow-panel[hidden]{display:none;} .ldp-user-card-follow-preview{z-index:2147483611;width:min(320px,calc(100vw - 20px)); max-height:min(520px,calc(100vh - 20px));} .ldp-user-card-follow-panel > header{display:flex;align-items:center;justify-content:space-between;gap:8px;} .ldp-user-card-follow-title{font-size:var(--ldp-font-ui,12px);} .ldp-user-card-breadcrumbs{display:flex;min-width:0;align-items:center;gap:4px;overflow-x:auto; color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);scrollbar-width:none;} .ldp-user-card-breadcrumbs[hidden]{display:none;} .ldp-user-card-breadcrumbs::-webkit-scrollbar{display:none;} .ldp-user-card-breadcrumbs button{flex:none;padding:1px 4px;border:0;border-radius:4px;background:transparent; color:inherit;font-size:inherit;cursor:pointer;} .ldp-user-card-breadcrumbs button:hover,.ldp-user-card-breadcrumbs button:focus-visible{ background:var(--primary-very-low,#f7f7f7);color:var(--tertiary,#08c);outline:none;} .ldp-user-card-breadcrumbs strong{flex:none;color:var(--primary,#222);} .ldp-user-card-follow-panel > header button{width:24px;height:24px;padding:0;border:0;border-radius:50%; background:transparent;color:var(--primary-medium,#777);font-size:18px;line-height:1;cursor:pointer;} .ldp-user-card-follow-panel > header button:hover{background:var(--primary-very-low,#f7f7f7);} .ldp-user-card-follow-search{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:7px; color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-user-card-follow-search input{min-width:0;height:30px;padding:4px 8px;border:1px solid var(--primary-low,#ddd); border-radius:7px;background:var(--secondary,#fff);color:var(--primary,#222);font:inherit;} .ldp-user-card-follow-search input:focus{border-color:var(--tertiary,#08c);outline:none;} .ldp-user-card-follow-summary{min-height:1.3em;color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);} .ldp-user-card-follow-list{display:grid;min-height:0;gap:2px;overflow:auto;overscroll-behavior:contain;} .ldp-user-card-follow-pagination{display:flex;align-items:center;justify-content:center;gap:8px; color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);} .ldp-user-card-follow-pagination[hidden]{display:none;} .ldp-user-card-follow-pagination button{padding:3px 8px;border:1px solid var(--primary-low,#ddd);border-radius:6px; background:var(--secondary,#fff);color:inherit;font:inherit;cursor:pointer;} .ldp-user-card-follow-pagination button:hover:not(:disabled), .ldp-user-card-follow-pagination button:focus-visible{border-color:var(--tertiary,#08c);color:var(--tertiary,#08c);outline:none;} .ldp-user-card-follow-pagination button:disabled{opacity:.45;cursor:not-allowed;} .ldp-user-card-follow-item{display:grid;grid-template-columns:30px minmax(0,1fr);align-items:center;gap:8px; min-width:0;padding:5px 6px;border-radius:7px;color:inherit;text-decoration:none;} .ldp-user-card-follow-item:hover,.ldp-user-card-follow-item:focus-visible{background:var(--primary-very-low,#f7f7f7);outline:none;} .ldp-user-card-follow-avatar{width:28px;height:28px;border-radius:50%;object-fit:cover;} .ldp-user-card-follow-item > span{display:grid;min-width:0;} .ldp-user-card-follow-item strong,.ldp-user-card-follow-item small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-user-card-follow-item strong{font-size:var(--ldp-font-ui,12px);} .ldp-user-card-follow-item small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);} .ldp-user-card-follow-empty{padding:12px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);text-align:center;} .ldp-user-card-bio{font-size:var(--ldp-font-ui,12px);line-height:1.45;color:var(--primary-medium,#666); margin:8px 0;max-height:96px;overflow:auto;overflow-wrap:anywhere;} .ldp-user-card-bio p{margin:0 0 4px;} .ldp-user-card-bio img.emoji{width:1.15em;height:1.15em;margin:0 .05em; vertical-align:-.2em;} .ldp-user-card-badges{display:grid;grid-template-columns:minmax(0,1fr);gap:5px;margin:7px 0;overflow:hidden;} .ldp-user-card-badges-label{display:block;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px); font-weight:600;line-height:1.35;} .ldp-user-card-badge-strip{display:grid;grid-template-columns:minmax(0,1fr);align-items:center;gap:1px;} .ldp-user-card-badge-strip.is-scrollable{grid-template-columns:18px minmax(0,1fr) 18px;} .ldp-user-card-badge-list{display:flex;width:100%;height:42px;min-width:0;align-items:center; flex-wrap:nowrap;gap:2px;overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;} .ldp-user-card-badge-list::-webkit-scrollbar{display:none;width:0;height:0;} .ldp-user-card-badge-scroll{display:grid;width:18px;height:38px;place-items:center;padding:0;border:0; border-radius:999px;background:transparent;color:var(--primary-medium,#777);cursor:pointer;} .ldp-user-card-badge-scroll:disabled{opacity:.2;cursor:default;} .ldp-reader-placement-trigger .ldp-icon,.ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open) .ldp-icon, .ldp-popover-search-clear .ldp-icon,.ldp-config-action .ldp-icon, .ldp-profile-field-sharing-button .ldp-icon,.ldp-user-card-badge-scroll .ldp-icon{width:14px;height:14px;} .ldp-user-card-badge{display:grid;width:34px;height:38px;flex:none;place-items:center;box-sizing:border-box; padding:0;border:0;border-radius:999px;background:transparent;color:var(--primary-high,#333);cursor:copy;} .ldp-user-card-badge-scroll:hover:not(:disabled),.ldp-user-card-badge:hover{background:color-mix(in srgb,currentColor 8%,transparent);} .ldp-user-card-badge-scroll:focus-visible,.ldp-user-card-badge:focus-visible{outline:2px solid var(--tertiary,#08c);outline-offset:1px;} .ldp-user-card-badge-icon{display:block;width:32px;height:32px;overflow:visible;fill:currentColor;stroke:none; stroke-linecap:round;stroke-linejoin:round;} .ldp-user-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:8px 0;} .ldp-user-card-stat{display:block;border:1px solid var(--primary-low,#eee);border-radius:7px;padding:5px; text-align:center;background:var(--primary-very-low,#f7f7f7);color:inherit;text-decoration:none;} .ldp-user-card-stat:hover{border-color:var(--tertiary,#08c);background:var(--secondary,#fff);} .ldp-user-card-stat strong{display:block;font-size:var(--ldp-font-base,13px);color:var(--primary,#222);} .ldp-user-card-stat span{font-size:var(--ldp-font-sm,11px);color:var(--primary-medium,#777);} .ldp-user-card-actions-wrap{position:relative;} .ldp-user-card-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px; margin:9px 0 5px;overflow:visible;} .ldp-user-card-action{position:relative;display:inline-flex;align-items:center;justify-content:center; min-width:0;height:36px;padding:0;border:1px solid var(--primary-low,#ddd);border-radius:7px; background:var(--secondary,#fff);color:var(--primary-high,#444);cursor:pointer;overflow:visible;} .ldp-user-card-action:first-child{border-color:var(--tertiary,#4f8a63); background:var(--tertiary,#4f8a63);color:var(--secondary,#fff);} .ldp-user-card-action:hover,.ldp-user-card-action:focus-visible{border-color:var(--tertiary,#4f8a63); background:color-mix(in srgb,var(--tertiary,#4f8a63) 10%,var(--secondary,#fff)); color:var(--tertiary,#4f8a63);outline:none;} .ldp-user-card-action:first-child:hover,.ldp-user-card-action:first-child:focus-visible{ background:color-mix(in srgb,var(--tertiary,#4f8a63) 86%,#000);color:var(--secondary,#fff);} .ldp-user-card-action.is-active{border-color:var(--tertiary,#4f8a63);color:var(--tertiary,#4f8a63);} .ldp-user-card-action:disabled{opacity:.42;cursor:not-allowed;} .ldp-user-card-action .ldp-icon{width:18px;height:18px;} .ldp-user-card-action::after{content:attr(data-tooltip);position:absolute;left:50%;bottom:calc(100% + 6px); z-index:2;width:max-content;max-width:180px;transform:translateX(-50%);padding:4px 7px; pointer-events:none;opacity:0;visibility:hidden;transition:none;} .ldp-user-card-action:first-child::after{left:0;transform:none;} .ldp-user-card-action:last-child::after{right:0;left:auto;transform:none;} .ldp-user-card-action:hover::after,.ldp-user-card-action:focus-visible::after{opacity:1;visibility:visible;} .ldp-user-card-notification-menu{position:fixed;z-index:4;display:grid;box-sizing:border-box;gap:4px;margin:0; max-height:calc(100vh - 20px);overflow:auto;overscroll-behavior:contain; padding:5px;border:1px solid var(--primary-low,#ddd);border-radius:8px;background:var(--secondary,#fff); box-shadow:0 10px 28px rgba(0,0,0,.16);} .ldp-user-card-notification-option{display:grid;grid-template-columns:28px minmax(0,1fr);align-items:center; box-sizing:border-box;gap:7px;width:100%;min-height:38px;padding:4px 7px;border:0;border-radius:6px;background:transparent;color:var(--primary,#222); text-align:left;cursor:pointer;} .ldp-user-card-notification-option:hover,.ldp-user-card-notification-option:focus-visible, .ldp-user-card-notification-option.is-active{background:color-mix(in srgb,var(--tertiary,#4f8a63) 12%,var(--secondary,#fff)); outline:none;} .ldp-user-card-notification-option .ldp-icon{width:18px;height:18px;justify-self:center;} .ldp-user-card-notification-option-copy{display:grid;gap:1px;min-width:0;} .ldp-user-card-notification-option-copy strong{font-size:var(--ldp-font-ui,12px);line-height:1.3;} .ldp-user-card-notification-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-xs,10px);line-height:1.35;} .ldp-user-card-notification-option:disabled{opacity:.5;cursor:wait;} .ldp-reader-icon-tooltip.ldp-transient-surface{position:fixed;z-index:2147483647;box-sizing:border-box;width:max-content;max-width:min(380px,calc(100vw - 16px)); padding:4px 7px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere; pointer-events:none;opacity:1;visibility:visible;transition:none;} .ldp-reader-icon-tooltip.ldp-reader-history-tooltip{max-width:min(34rem,calc(100vw - 16px)); overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;} .ldp-reader-icon-tooltip.ldp-connect-help-tooltip{max-width:min(320px,calc(100vw - 16px)); padding:7px 9px;overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere;line-height:1.5;} .ldp-user-card-action-status{min-width:0;color:var(--primary-medium,#777); font-size:var(--ldp-font-sm,11px);line-height:1.35;} .ldp-user-card-action-status:not(:empty){margin-top:7px;} .ldp-user-card-action-status.is-error{color:var(--danger,#c33);} .ldp-user-card-home{position:absolute;top:12px;right:12px;display:inline-flex;align-items:center;justify-content:center; width:36px;height:36px;box-sizing:border-box;border:0;background:transparent; color:var(--primary-high,#444);text-decoration:none;} .ldp-user-card-home:hover{color:var(--tertiary,#4f8a63);} .ldp-user-card-home:focus-visible{color:var(--tertiary,#4f8a63); outline:2px solid currentColor;outline-offset:2px;border-radius:4px;} .ldp-user-card-home .ldp-icon{width:18px;height:18px;} .ldp-user-card-home::after{content:attr(data-tooltip);position:absolute;top:calc(100% + 6px);right:0; z-index:2;width:max-content;padding:4px 7px;border-radius:5px;background:rgba(30,30,30,.94);color:#fff; font-size:var(--ldp-font-sm,11px);font-weight:500;line-height:1.3;white-space:nowrap;pointer-events:none; opacity:0;visibility:hidden;transition:none;} .ldp-user-card-home:hover::after,.ldp-user-card-home:focus-visible::after{opacity:1;visibility:visible;} .ldp-user-card-progress{display:grid;grid-template-columns:minmax(0,1fr) 72px;align-items:center;gap:8px; margin:7px 0 2px;color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);} .ldp-user-card-progress-track{height:4px;overflow:hidden;border-radius:999px;background:var(--primary-low,#e4e4e4);} .ldp-user-card-progress-fill{display:block;width:34%;height:100%;border-radius:inherit;background:var(--tertiary,#08c); animation:ldp-user-card-progress 1.1s ease-in-out infinite alternate;} .ldp-user-card-progress.is-stats .ldp-user-card-progress-fill{width:72%;} @keyframes ldp-user-card-progress{from{transform:translateX(-15%);}to{transform:translateX(115%);}} .ldp-transient-surface, .ldp-reader-placement-option::after, .ldp-user-card-action::after{border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff; font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.18);} .ldp-transient-surface.ldp-setting-help-tooltip{padding:7px 9px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;} .ldp-action-surface{border:1px solid var(--primary-low,#ddd);border-radius:7px; background:var(--secondary,#fff);color:var(--primary,#222);font-size:var(--ldp-font-ui,12px); font-weight:600;line-height:1.35;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.14);} .ldp-selection-toolbar{position:fixed;z-index:2147483610;display:flex;overflow:hidden;} .ldp-selection-toolbar button{min-height:30px;padding:5px 9px;border:0;border-right:1px solid var(--primary-low,#ddd); background:transparent;color:inherit;font:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;cursor:pointer;} .ldp-selection-toolbar button:last-child{border-right:0;} .ldp-selection-toolbar button:hover,.ldp-selection-toolbar button:focus-visible{ background:color-mix(in srgb,var(--tertiary,#47855f) 12%,var(--secondary,#fff)); color:var(--tertiary,#47855f);outline:none;} .ldp-selection-toast{position:fixed;left:50%;bottom:28px;z-index:2147483611;transform:translateX(-50%); padding:8px 12px;border-radius:6px;background:rgba(32,32,32,.9);color:#fff;font-size:var(--ldp-font-base,13px); box-shadow:0 6px 20px rgba(0,0,0,.2);pointer-events:none;} .ldp-reader-action-layer{position:absolute;inset:0;z-index:2147483612;display:flex; align-items:center;justify-content:center;padding:16px;box-sizing:border-box; background:color-mix(in srgb,rgba(0,0,0,.46) 84%,transparent);} .ldp-reader-action-dialog{display:flex;flex-direction:column;width:min(430px,100%);max-height:min(620px,calc(100% - 16px)); overflow:hidden;border:1px solid var(--primary-low,#ddd);border-radius:8px; background:var(--secondary,#fff);color:var(--primary,#222);box-shadow:0 18px 54px rgba(0,0,0,.28);} .ldp-reader-action-head{display:flex;align-items:center;justify-content:space-between;gap:12px; min-height:46px;padding:7px 9px 7px 14px;box-sizing:border-box;border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-reader-action-head strong{min-width:0;font-size:var(--ldp-font-lg,15px);line-height:1.35;overflow-wrap:anywhere;} .ldp-reader-action-close{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center; flex:none;padding:0;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;} .ldp-reader-action-close:hover{background:var(--primary-very-low,#f2f2f2);} .ldp-reader-action-form{display:flex;flex-direction:column;min-height:0;} .ldp-reader-action-body{display:grid;gap:13px;min-height:0;padding:14px;overflow-y:auto;overscroll-behavior:contain;} .ldp-reader-action-options{display:grid;gap:7px;} .ldp-reader-action-option{display:grid;grid-template-columns:18px minmax(0,1fr);gap:9px;align-items:start; padding:9px;border:1px solid var(--primary-low,#ddd);border-radius:7px;cursor:pointer;} .ldp-reader-action-option:has(input:checked){border-color:color-mix(in srgb,var(--tertiary,#47855f) 55%,var(--primary-low,#ddd)); background:color-mix(in srgb,var(--tertiary-low,#dceee2) 44%,var(--secondary,#fff));} .ldp-reader-action-option input{margin:3px 0 0;} .ldp-reader-action-option-copy{display:grid;gap:2px;min-width:0;} .ldp-reader-action-option-copy small{color:var(--primary-medium,#777);font-size:var(--ldp-font-sm,11px);line-height:1.4;} .ldp-reader-action-field{display:grid;gap:5px;font-size:var(--ldp-font-ui,12px);font-weight:650;} .ldp-reader-action-field input,.ldp-reader-action-field textarea,.ldp-reader-action-field select{width:100%;box-sizing:border-box; border:1px solid var(--primary-low,#ccc);border-radius:7px;padding:8px 9px; background:var(--secondary,#fff);color:inherit;font:inherit;line-height:1.45;outline:none;} .ldp-boost-copy-mode,.ldp-font-weight-select,.ldp-reader-action-field .ldp-reader-select{font-size:var(--ldp-font-base,13px);font-weight:600;line-height:1.35;} .ldp-reader-action-field textarea{min-height:86px;resize:vertical;} .ldp-reader-action-field input:focus,.ldp-reader-action-field textarea:focus,.ldp-reader-action-field select:focus{border-color:var(--tertiary,#47855f); box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-reader-action-status{min-height:18px;margin:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;} .ldp-reader-action-status.success{color:var(--success,#47855f);} .ldp-reader-action-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px; padding:9px 14px;border-top:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-reader-action-cancel,.ldp-reader-action-submit{min-height:34px;padding:6px 13px;border-radius:6px; font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;} .ldp-reader-action-cancel{border:1px solid var(--primary-low,#ddd);background:transparent;color:inherit;} .ldp-reader-action-cancel:hover{background:var(--primary-very-low,#f2f2f2);} .ldp-reader-action-submit:disabled,.ldp-reader-action-cancel:disabled{cursor:default;opacity:.48;} .ldp-reader-confirm-layer{position:fixed;z-index:2147483612;pointer-events:auto; background:color-mix(in srgb,rgba(0,0,0,.38) 82%,transparent);} .ldp-reader-confirm-dialog{width:min(380px,100%);border:0;border-radius:12px; box-shadow:0 8px 24px rgba(0,0,0,.24);animation:ldp-reader-confirm-in .18s cubic-bezier(.22,1,.36,1);} .ldp-reader-confirm-content{display:grid;grid-template-columns:40px minmax(0,1fr);gap:12px;padding:18px 18px 14px;} .ldp-reader-confirm-icon{display:inline-flex;width:40px;height:40px;align-items:center;justify-content:center; border-radius:10px;background:var(--danger-low,#f5d7d7);color:var(--danger,#c94747);} .ldp-reader-confirm-icon .ldp-icon{width:19px;height:19px;} .ldp-reader-confirm-copy{display:grid;gap:5px;min-width:0;} .ldp-reader-confirm-copy strong{font-size:var(--ldp-font-lg,15px);line-height:1.4;overflow-wrap:anywhere;} .ldp-reader-confirm-copy p{margin:0;color:var(--primary-high,#444);font-size:var(--ldp-font-base,13px);line-height:1.55;overflow-wrap:anywhere;} .ldp-reader-confirm-details{display:grid;gap:5px;margin:4px 0 0;padding:0;list-style:none;} .ldp-reader-confirm-details li{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px; border-radius:6px;background:var(--primary-very-low,#f2f2f2);font-size:var(--ldp-font-ui,12px);line-height:1.4;} .ldp-reader-confirm-details strong{font-size:inherit;white-space:nowrap;} .ldp-reader-confirm-dialog .ldp-reader-action-footer{padding:0 18px 18px;border-top:0;} .ldp-reader-confirm-dialog .ldp-reader-action-cancel,.ldp-reader-confirm-dialog .ldp-reader-action-secondary, .ldp-reader-confirm-dialog .ldp-reader-action-submit{min-height:36px;padding:7px 15px;} .ldp-reader-action-secondary{border:1px solid color-mix(in srgb,var(--danger,#c94747) 38%,var(--primary-low,#ddd)); border-radius:6px;background:transparent;color:var(--danger,#c94747);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;} .ldp-reader-action-secondary:hover{background:var(--danger-low,#f5d7d7);} .ldp-reader-confirm-dialog .ldp-reader-action-submit{background:var(--danger,#c94747);} .ldp-reader-confirm-dialog.is-primary .ldp-reader-confirm-icon{background:var(--tertiary-low,#dceee2);color:var(--tertiary,#47855f);} .ldp-reader-confirm-dialog.is-primary .ldp-reader-action-submit{background:var(--tertiary,#47855f);} .ldp-native-reader-trigger:focus-visible,.ldp-reader-host-top:focus-visible,.ldp-user-info-tab:focus-visible, .ldp-rate-limit-challenge:focus-visible,.ldp-topic-timeline-top:focus-visible,.ldp-composer-drag-handle:focus-visible, .ldp-reader-confirm-dialog :is(.ldp-reader-action-cancel,.ldp-reader-action-secondary,.ldp-reader-action-submit):focus-visible{ outline:2px solid var(--tertiary,#47855f);outline-offset:2px;} @keyframes ldp-reader-confirm-in{from{opacity:0;transform:translateY(6px) scale(.98);}to{opacity:1;transform:none;}} @media (prefers-reduced-motion:reduce){.ldp-reader-confirm-dialog{animation:none;}} .ldp-error{display:grid;justify-items:center;align-content:center;gap:16px;min-height:220px; box-sizing:border-box;padding:28px;text-align:center;color:var(--primary-medium,#666);} .ldp-error-message{max-width:560px;line-height:1.6;overflow-wrap:anywhere;} .ldp-error-actions{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;} .ldp-error-retry,.ldp-error-challenge,.ldp-error-close{min-height:36px;padding:7px 14px;border-radius:7px; border:1px solid var(--primary-low,#d9d9d9);font:inherit;font-size:var(--ldp-font-base,13px);font-weight:650;cursor:pointer;} .ldp-error-retry{border-color:transparent;background:var(--tertiary,#47855f);color:#fff;} .ldp-error-challenge{display:inline-flex;align-items:center;text-decoration:none;} .ldp-error-challenge,.ldp-error-close{background:var(--secondary,#fff);color:inherit;} .ldp-error-challenge:hover,.ldp-error-close:hover{background:var(--primary-very-low,#f3f3f3);} .ldp-error-retry:disabled{cursor:default;opacity:.52;} .ldp-sentinel{height:1px;} /* 图片浏览器 */ .ldp-lightbox{position:fixed;inset:0;z-index:2147483600;display:flex;flex-direction:column; min-width:0;min-height:0;background:rgba(7,9,12,.8);color:#fff;isolation:isolate; --ldp-lb-comments-width-preferred:25%;--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);} .ldp-lb-toolbar{height:54px;display:flex;align-items:center;gap:10px;flex:none; box-sizing:border-box;padding:8px 14px;border-bottom:0; background:rgba(10,12,16,.5);} .ldp-lb-count{min-width:54px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;color:rgba(255,255,255,.72);} .ldp-lb-tools{display:flex;align-items:center;justify-content:center;gap:4px;flex:1;min-width:0;} .ldp-lb-btn,.ldp-lb-nav{display:inline-flex;align-items:center;justify-content:center;border:0; color:#fff;background:rgba(255,255,255,.1);cursor:pointer;transition:background-color .16s ease,opacity .16s ease;} .ldp-lb-btn{width:36px;height:36px;border-radius:8px;} .ldp-lb-btn:hover:not(:disabled),.ldp-lb-nav:hover:not(:disabled):not([aria-disabled="true"]){background:rgba(255,255,255,.2);} .ldp-lb-btn:disabled{opacity:.4;cursor:default;} .ldp-lb-btn:focus-visible,.ldp-lb-nav:focus-visible,.ldp-lb-thumb:focus-visible{ outline:2px solid #8cc7ff;outline-offset:2px;} .ldp-lb-btn .ldp-icon,.ldp-lb-nav .ldp-icon{width:19px;height:19px;} .ldp-lb-zoom-value{width:58px;font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;} .ldp-lb-close{margin-left:auto;} .ldp-lb-main{position:relative;display:flex;flex:1;min-width:0;min-height:0;} .ldp-lb-stage{position:relative;display:flex;align-items:center;justify-content:center;flex:1; min-width:0;min-height:0;overflow:hidden;padding:18px 64px;box-sizing:border-box;touch-action:none;} .ldp-lb-canvas{position:relative;display:flex;align-items:center;justify-content:center; max-width:100%;max-height:100%;} .ldp-lb-image{display:block;max-width:100%;max-height:calc(100vh - 176px);width:auto;height:auto; border-radius:4px;user-select:none;-webkit-user-drag:none;transform:translate3d(var(--ldp-lb-pan-x,0),var(--ldp-lb-pan-y,0),0) scale(var(--ldp-lb-scale,1)); transform-origin:center center;will-change:transform;cursor:zoom-in;} .ldp-lb-image.is-zoomed{cursor:grab;} .ldp-lb-image.is-dragging{cursor:grabbing;} .ldp-lb-status{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex; flex-direction:column;align-items:center;gap:10px;max-width:min(420px,70vw);text-align:center; color:rgba(255,255,255,.78);font-size:var(--ldp-font-base,13px);} .ldp-lb-retry{border:0;border-radius:7px;padding:7px 12px;background:#fff;color:#111;cursor:pointer;font:inherit;} .ldp-lb-nav{position:absolute;top:50%;z-index:2;width:42px;height:56px;border-radius:10px; transform:translateY(-50%);background:rgba(0,0,0,.42);} .ldp-lb-prev{left:12px;} .ldp-lb-next{right:12px;} .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:calc(var(--ldp-lb-comments-width) + 12px);} .ldp-lb-nav:disabled,.ldp-lb-nav[aria-disabled="true"]{opacity:.22;cursor:default;} .ldp-lb-comments-toggle{gap:5px;width:auto;min-width:36px;padding:0 9px;white-space:nowrap;} .ldp-lb-comments-toggle[aria-expanded="true"]{background:rgba(255,255,255,.24);} .ldp-lb-comments-count{font-size:var(--ldp-font-sm,11px);font-variant-numeric:tabular-nums;} .ldp-lb-comments{--primary:#f5f7fa;--primary-high:#fff;--primary-medium:rgba(245,247,250,.72); --primary-low:rgba(245,247,250,.14);--primary-low-mid:rgba(245,247,250,.22); --primary-very-low:rgba(245,247,250,.08);--secondary:transparent; --d-post-aside-background:var(--primary-very-low); position:relative;display:flex;flex:none;flex-direction:column;width:var(--ldp-lb-comments-width);min-width:0; overflow:hidden;box-sizing:border-box;border-left:0; background:rgba(7,9,12,.65);color:var(--primary); transition:width .18s ease,border-color .18s ease;} .ldp-lb-comments-collapsed .ldp-lb-comments{width:0;border-left-color:transparent;} .ldp-lb-comments-resizer{position:absolute;z-index:7;top:0;bottom:0;left:-6px;width:12px;padding:0;border:0; background:transparent;cursor:col-resize;touch-action:none;} .ldp-lb-comments-resizer::after{content:"";position:absolute;top:50%;left:5px;width:2px;height:46px; border-radius:999px;background:rgba(245,247,250,.2);transform:translateY(-50%);transition:background-color .16s ease;} .ldp-lb-comments-resizer:hover::after,.ldp-lb-comments-resizer:focus-visible::after,.ldp-lightbox.is-resizing-comments .ldp-lb-comments-resizer::after{ background:rgba(245,247,250,.72);} .ldp-lb-comments-resizer:focus-visible{outline:2px solid #8cc7ff;outline-offset:-2px;} .ldp-lb-comments-collapsed .ldp-lb-comments-resizer{pointer-events:none;opacity:0;} .ldp-lightbox.is-resizing-comments,.ldp-lightbox.is-resizing-comments *{cursor:col-resize!important;user-select:none!important;} .ldp-lb-comments-inner{display:flex;flex:1;flex-direction:column;width:100%;min-height:0;} .ldp-lb-comments-head{display:flex;align-items:center;gap:8px;min-height:50px;padding:8px 14px; box-sizing:border-box;border-bottom:0;} .ldp-lb-comments-head strong{font-size:var(--ldp-font-lg,15px);line-height:1.3;} .ldp-lb-comments-head span{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;} .ldp-lb-description-toggle{display:inline-flex;align-items:center;justify-content:center;width:1em;height:1em;padding:0;border:0; background:transparent;color:var(--primary-medium,#777);cursor:pointer;font:inherit;font-size:var(--ldp-font-lg,15px);line-height:1;} .ldp-content .ldp-callout-toggle .ldp-icon,.ldp-lb-description-toggle .ldp-icon{width:1em;height:1em;transition:transform .16s ease;} .ldp-lb-description-toggle[aria-expanded="true"] .ldp-icon{transform:rotate(90deg);} .ldp-lb-description-toggle:hover{color:var(--primary,#f5f7fa);} .ldp-lb-description-toggle:focus-visible{outline:2px solid #8cc7ff;outline-offset:2px;} .ldp-lb-source{margin:0;border-bottom:0;background:var(--primary-very-low,#f7f7f7);} .ldp-lb-source:has(+ .ldp-lb-source-reactions:not([hidden])){border-bottom-color:transparent;} .ldp-lb-source summary{display:none;} .ldp-lb-source-text{box-sizing:border-box;height:var(--ldp-lb-description-height,120px);min-height:56px;max-height:40vh; overflow:auto;overscroll-behavior:contain;resize:vertical;padding:10px 14px 11px;color:var(--primary-medium,#666); font-size:var(--ldp-font-ui,12px);line-height:1.55;overflow-wrap:anywhere;} .ldp-lb-source-reactions{padding:7px 14px 10px;border-bottom:0;background:var(--primary-very-low,#f7f7f7);} .ldp-lb-source-reactions > .ldp-reactions{margin:0;} .ldp-lb-source-reactions .ldp-reaction-chip,.ldp-lb-comment-node .ldp-reaction-chip{min-height:24px;} .ldp-lb-source-reactions .ldp-reaction-chip > span,.ldp-lb-comment-node .ldp-reaction-chip > span{font-size:18px;line-height:1;} .ldp-lb-source-reactions .ldp-reaction-chip img.emoji,.ldp-lb-comment-node .ldp-reaction-chip img.emoji{width:18px;height:18px;} .ldp-lb-source-reactions .ldp-reaction-picker,.ldp-lb-comment-node .ldp-reaction-picker{top:calc(100% + 5px);bottom:auto;} .ldp-lb-comments-body{display:flex;flex:1;flex-direction:column;min-height:0;overflow-y:auto;overscroll-behavior:contain;} .ldp-lb-comments-status,.ldp-lb-comments-empty{display:grid;justify-items:center;gap:10px;padding:28px 18px; color:var(--primary-medium,#777);text-align:center;font-size:var(--ldp-font-base,13px);line-height:1.55;} .ldp-lb-comment-list{display:block;min-height:0;} .ldp-lb-comment-thread{border-bottom:0;} .ldp-lb-comment-node > .ldp-post{padding:12px 14px;border-bottom:0;content-visibility:visible;contain-intrinsic-size:none;} .ldp-lb-comment-children{margin-left:18px;border-left:var(--ldp-reply-line-secondary-width,2px) solid var(--ldp-reply-line-color,#47855f);} .ldp-lb-comment-children .ldp-lb-comment-node{border-top:0;} .ldp-lb-comment-children .ldp-post{padding-left:12px;} .ldp-lb-comment-node .ldp-post-head{align-items:flex-start;flex-wrap:wrap;gap:5px 7px;} .ldp-lb-comment-node .ldp-avatar{width:26px;height:26px;} .ldp-lb-comment-node .ldp-author{max-width:44%;font-size:var(--ldp-font-ui,12px);line-height:1.35;} .ldp-lb-comment-node .ldp-content{font-size:var(--ldp-font-base,13px);line-height:1.6;} .ldp-lb-comment-node .ldp-content > :first-child{margin-top:0;} .ldp-lb-comment-node .ldp-content > :last-child{margin-bottom:0;} .ldp-lb-comment-node .ldp-reactions{margin:8px 0 0;} .ldp-lb-comment-node .ldp-context-actions-slot,.ldp-lb-comment-node .ldp-boostbtn, .ldp-lb-comment-node .ldp-boost-list,.ldp-lb-comment-node .ldp-topic-footer-actions, .ldp-lb-comment-node .ldp-special-badges{display:none!important;} .ldp-lb-comment-node .ldp-floor{cursor:pointer;} .ldp-lb-comment-node .ldp-floor:hover{color:var(--tertiary,#47855f);text-decoration:underline;} .ldp-lb-add,.ldp-lb-comment-submit,.ldp-lb-comment-cancel{min-height:34px;padding:6px 11px;border-radius:7px; font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;cursor:pointer;} .ldp-topic-edit-save,.ldp-reader-action-submit,.ldp-lb-add,.ldp-lb-comment-submit{border:1px solid transparent;background:var(--tertiary,#47855f);color:#fff;} .ldp-lb-comment-cancel{border:1px solid var(--primary-low,#ddd);background:var(--secondary,#fff);color:inherit;} .ldp-lb-comment-form{display:grid;gap:8px;margin:0;padding:12px 14px;border-top:0; background:var(--secondary,#fff);} .ldp-lb-comment-target{color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);line-height:1.4;} .ldp-lb-comment-input{box-sizing:border-box;width:100%;min-height:88px;resize:vertical;padding:9px 10px; border:1px solid var(--primary-low,#ccc);border-radius:7px;background:var(--secondary,#fff);color:inherit; font:inherit;font-size:var(--ldp-font-base,13px);line-height:1.55;} .ldp-lb-comment-input::placeholder{color:var(--primary-medium);opacity:1;} .ldp-boost-rule-control:focus,.ldp-lb-comment-input:focus{border-color:var(--tertiary,#47855f);outline:none;box-shadow:0 0 0 1px var(--tertiary,#47855f);} .ldp-lb-comment-image-option{display:flex;align-items:center;gap:7px;color:var(--primary-medium,#666);font-size:var(--ldp-font-ui,12px);cursor:pointer;} .ldp-lb-comment-image-option input{margin:0;accent-color:var(--tertiary,#47855f);} .ldp-lb-comment-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;} .ldp-lb-comment-form[aria-busy="true"] :is(textarea,button,input){pointer-events:none;opacity:.55;} .ldp-lb-comment-error{min-height:0;color:var(--danger,#c44);font-size:var(--ldp-font-ui,12px);line-height:1.45;} .ldp-lb-filmstrip{flex:none;box-sizing:border-box;padding:7px 12px 10px; border-top:0;background:rgba(10,12,16,.5);} .ldp-lb-strip-progress{height:2px;margin:0 4px 7px;border-radius:2px;background:rgba(255,255,255,.12);overflow:hidden;} .ldp-lb-strip-progress > span{display:block;height:100%;width:var(--ldp-lb-progress-size,100%); background:#8cc7ff;transform:translateX(var(--ldp-lb-progress-x,0));transition:transform .18s ease;} .ldp-lb-thumbs{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain; scroll-snap-type:x proximity;padding:2px 4px 4px;scrollbar-width:thin;} .ldp-lb-thumb{width:64px;height:56px;flex:none;padding:2px;border:2px solid transparent;border-radius:8px; background:transparent;cursor:pointer;scroll-snap-align:center;opacity:.62;box-sizing:border-box;} .ldp-lb-thumb:hover{opacity:.9;} .ldp-lb-thumb.active{border-color:#8cc7ff;opacity:1;} .ldp-lb-thumb img{display:block;width:100%;height:100%;border-radius:4px;object-fit:cover;background:#20242a;} .ldp-lb-batch-overlay{position:absolute;z-index:12;inset:0;display:grid;place-items:center;padding:16px; box-sizing:border-box;background:rgba(0,0,0,.58);backdrop-filter:blur(3px);} .ldp-lb-batch-dialog{display:flex;flex-direction:column;width:min(720px,calc(100vw - 32px));max-height:min(720px,calc(100vh - 32px)); overflow:hidden;border:1px solid rgba(255,255,255,.16);border-radius:12px;background:#24272c;color:#f5f7fa; box-shadow:0 18px 54px rgba(0,0,0,.42);} .ldp-lb-batch-head,.ldp-lb-batch-tools,.ldp-lb-batch-actions{display:flex;align-items:center;gap:9px;} .ldp-lb-batch-head{min-height:48px;padding:8px 12px 8px 16px;border-bottom:1px solid rgba(255,255,255,.1);} .ldp-lb-batch-head strong{font-size:var(--ldp-font-lg,15px);}.ldp-lb-batch-head .ldp-lb-batch-close{margin-left:auto;} .ldp-lb-batch-name{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:9px;padding:12px 16px 8px; color:rgba(245,247,250,.72);font-size:var(--ldp-font-ui,12px);} .ldp-lb-batch-name input{min-width:0;height:34px;box-sizing:border-box;padding:6px 9px;border:1px solid rgba(255,255,255,.16); border-radius:7px;background:rgba(255,255,255,.07);color:#fff;font:inherit;outline:none;} .ldp-lb-batch-name input:focus{border-color:#8cc7ff;box-shadow:0 0 0 1px #8cc7ff;} .ldp-lb-batch-tools{min-height:42px;padding:0 16px 8px;color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);} .ldp-lb-batch-scope{display:flex;align-self:stretch;align-items:stretch;gap:18px;border-bottom:1px solid rgba(255,255,255,.1);} .ldp-lb-batch-scope button{position:relative;min-height:38px;padding:6px 2px 9px;border:0;background:transparent; color:rgba(245,247,250,.58);cursor:pointer;font:inherit;font-weight:650;white-space:nowrap;} .ldp-lb-batch-scope button::after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0; background:#8cc7ff;opacity:0;transform:scaleX(.5);transition:opacity .14s ease,transform .14s ease;} .ldp-lb-batch-scope button:hover:not(:disabled){color:rgba(245,247,250,.88);} .ldp-lb-batch-scope button[aria-selected="true"]{color:#fff;} .ldp-lb-batch-scope button[aria-selected="true"]::after{opacity:1;transform:scaleX(1);} .ldp-lb-batch-scope button:focus-visible,.ldp-lb-batch-select-all:focus-visible,.ldp-lb-batch-more:focus-visible{outline:2px solid rgba(140,199,255,.72);outline-offset:2px;} .ldp-lb-batch-scope button:disabled{opacity:.4;cursor:default;} .ldp-lb-batch-select-all{display:inline-flex;min-height:32px;align-items:center;gap:6px;margin-left:2px;padding:5px 9px;border:0; border-radius:7px;background:rgba(255,255,255,.055);color:rgba(245,247,250,.72);cursor:pointer;font:inherit;font-weight:650;} .ldp-lb-batch-select-all:hover:not(:disabled){background:rgba(255,255,255,.1);color:#fff;} .ldp-lb-batch-select-all[aria-pressed="true"]{background:rgba(140,199,255,.14);color:#b9dcff;} .ldp-reader-host-top .ldp-icon,.ldp-settings-theme-button .ldp-icon,.ldp-rate-limit-icon .ldp-icon, .ldp-topic-timeline-top .ldp-icon,.ldp-lb-batch-select-all .ldp-icon{width:15px;height:15px;} .ldp-lb-batch-select-all:disabled{opacity:.4;cursor:default;} .ldp-lb-batch-count{margin-left:auto;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.055); color:rgba(245,247,250,.68);font-variant-numeric:tabular-nums;white-space:nowrap;} .ldp-lb-batch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:9px;min-height:0;overflow:auto; padding:2px 16px 14px;overscroll-behavior:contain;} .ldp-lb-batch-item{position:relative;display:grid;gap:5px;padding:5px;border:1px solid rgba(255,255,255,.12);border-radius:8px; background:rgba(255,255,255,.035);cursor:pointer;}.ldp-lb-batch-item.selected{border-color:#8cc7ff;background:rgba(140,199,255,.12);} .ldp-lb-batch-item img{display:block;width:100%;aspect-ratio:4/3;border-radius:5px;object-fit:cover; background:linear-gradient(110deg,rgba(255,255,255,.045) 8%,rgba(255,255,255,.11) 18%,rgba(255,255,255,.045) 33%);background-size:220% 100%; animation:ldp-lb-batch-thumbnail-loading 1.25s linear infinite;} .ldp-lb-batch-item img[data-ldp-batch-thumb-state="loaded"]{background:repeating-conic-gradient(#d8dbe0 0 25%,#f3f4f6 0 50%) 50%/14px 14px;animation:none;} .ldp-lb-batch-item img[data-ldp-batch-thumb-state="failed"]{opacity:.28;animation:none;} .ldp-lb-batch-item:has(img[data-ldp-batch-thumb-state="failed"])::after{content:"预览加载失败";position:absolute;left:8px;right:8px;top:50%; color:rgba(245,247,250,.82);font-size:var(--ldp-font-sm,11px);text-align:center;transform:translateY(-12px);pointer-events:none;} @keyframes ldp-lb-batch-thumbnail-loading{to{background-position-x:-220%;}} .ldp-lb-batch-item input{position:absolute;top:9px;left:9px;width:17px;height:17px;margin:0;accent-color:#47855f;cursor:pointer;} .ldp-lb-batch-item span{overflow:hidden;color:rgba(245,247,250,.72);font-size:var(--ldp-font-sm,11px);text-overflow:ellipsis;white-space:nowrap;} .ldp-lb-batch-more{display:grid;min-height:124px;place-items:center;align-content:center;gap:8px;padding:10px;border:1px dashed rgba(140,199,255,.42); border-radius:8px;background:rgba(140,199,255,.045);color:rgba(185,220,255,.78);cursor:pointer;font:inherit;font-size:var(--ldp-font-sm,11px);font-weight:650;} .ldp-lb-batch-more:hover:not(:disabled){border-color:#8cc7ff;background:rgba(140,199,255,.1);color:#d9edff;} .ldp-lb-batch-more:disabled{opacity:.55;cursor:wait;}.ldp-lb-batch-more .ldp-icon{width:24px;height:24px;} .ldp-lb-batch-progress{display:grid;gap:5px;padding:9px 16px;border-top:1px solid rgba(255,255,255,.08);} .ldp-lb-batch-progress-copy{display:flex;justify-content:space-between;gap:10px; color:rgba(245,247,250,.68);font-size:var(--ldp-font-ui,12px);font-variant-numeric:tabular-nums;} .ldp-lb-batch-progress-track{height:6px;overflow:hidden;border-radius:999px;background:rgba(255,255,255,.12);} .ldp-lb-batch-progress-fill{display:block;width:var(--ldp-lb-batch-progress,0%);height:100%;border-radius:inherit; background:#8cc7ff;transition:width .18s ease;} .ldp-lb-batch-actions{justify-content:flex-end;padding:10px 16px 14px;border-top:1px solid rgba(255,255,255,.1);} .ldp-lb-batch-status{min-width:0;margin-right:auto;color:rgba(245,247,250,.65);font-size:var(--ldp-font-ui,12px);} .ldp-lb-batch-cancel,.ldp-lb-batch-download{min-height:34px;padding:6px 12px;border-radius:7px;cursor:pointer;font:inherit;font-size:var(--ldp-font-ui,12px);font-weight:700;} .ldp-lb-batch-cancel{border:1px solid rgba(255,255,255,.16);background:transparent;color:#fff;} .ldp-lb-batch-download{border:1px solid transparent;background:#47855f;color:#fff;}.ldp-lb-batch-download:disabled{opacity:.45;cursor:default;} @media (max-width:700px){ .ldp-lightbox{--ldp-lb-comments-width:clamp(min(240px,50%),var(--ldp-lb-comments-width-preferred),50%);} .ldp-lb-toolbar{height:50px;padding:6px 8px;gap:4px;} .ldp-lb-count{min-width:42px;} .ldp-lb-btn{width:34px;height:34px;} .ldp-lb-stage{padding:12px 46px;} .ldp-lb-image{max-height:calc(100vh - 154px);} .ldp-lb-nav{width:36px;height:48px;} .ldp-lb-prev{left:5px;}.ldp-lb-next{right:5px;} .ldp-lb-comments{position:absolute;z-index:5;top:0;right:0;bottom:0;width:var(--ldp-lb-comments-width); box-shadow:-12px 0 34px rgba(0,0,0,.22);transition:transform .18s ease;} .ldp-lb-comments-collapsed .ldp-lb-comments{width:var(--ldp-lb-comments-width);border-left-color:var(--ldp-divider-line-color,#e5e5e5);transform:translateX(100%);} .ldp-lightbox:not(.ldp-lb-comments-collapsed) .ldp-lb-next{right:5px;} .ldp-lb-filmstrip{padding:6px 6px 8px;} .ldp-lb-thumb{width:54px;height:48px;} .ldp-lb-batch-grid{grid-template-columns:repeat(auto-fill,minmax(86px,1fr));} } @media (prefers-reduced-motion:reduce){ .ldp-reader-window-capsule,.ldp-reader-drag-handle,.ldp-reader-lock-button,.ldp-reader-pin-button, html .ldp-reader-switch-target,html.ldp-reader-embedded-left #ember-app,html.ldp-reader-embedded-right #ember-app, html.ldp-reader-embedded-left .d-header,html.ldp-reader-embedded-right .d-header,.ldp-reader-embedded .ldp-modal, .ldp-topic-edit-category-trigger .ldp-icon,.ldp-color-group-head, .ldp-settings-popover input[type="range"]::-webkit-slider-thumb, .ldp-settings-popover input[type="range"]::-moz-range-thumb,.ldp-profile-field-sharing-button, .ldp-setting-switch-track,.ldp-setting-switch-track::after,.ldp-color-control input[type="color"], .ldp-font-family-trigger .ldp-icon,.ldp-setting-help-tooltip,.ldp-topic-timeline-preview,.ldp-topic-timeline-count, .ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span,.ldp-topic-timeline-cursor > span::before, .ldp-nested-rail-toggle::before,.ldp-boost-item-action{transition:none;} .ldp-lb-btn,.ldp-lb-nav,.ldp-lb-strip-progress > span,.ldp-lb-comments,.ldp-lb-source summary .ldp-icon, .ldp-avatar-viewer-progress-fill,.ldp-topic-timeline-cursor,.ldp-topic-timeline-cursor > span{transition:none;animation:none;} } /* 楼中楼分页按钮 */ .ldp-collapse-replies{display:none;margin:8px 0 2px 22px;padding-left:0; width:calc(100% - 22px);justify-content:flex-start; font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600;} .ldp-collapse-replies.show{display:flex;} .ldp-sub-actions{margin-top:6px;display:none;align-items:center;gap:8px;font-size:var(--ldp-font-ui,12px);} .ldp-sub-page-btn{font-size:var(--ldp-font-ui,12px);color:var(--tertiary,#08c);font-weight:600; opacity:.9;padding:4px 0;} .ldp-sub-page-btn:hover:not(:disabled){opacity:1;text-decoration:underline;} .ldp-sub-page-btn:disabled{opacity:.35;cursor:default;} .ldp-topic-edit-category-trigger[aria-expanded="true"] .ldp-icon,.ldp-notification-prev-icon, .ldp-font-family-trigger[aria-expanded="true"] .ldp-icon,.ldp-reader-history-back .ldp-icon, .ldp-content .ldp-callout--foldable:not(.ldp-callout--collapsed) > .ldp-callout-toggle .ldp-icon, .ldp-content aside.ldp-post-quote.ldp-quote-expanded .ldp-quote-toggle .ldp-icon, .ldp-content .ldp-media-carousel-controls button:first-child .ldp-icon,.ldp-user-card-badge-scroll.is-prev .ldp-icon, .ldp-lb-prev .ldp-icon,.ldp-sub-page-prev .ldp-icon{transform:rotate(180deg);} .ldp-sub-page-info{color:var(--primary-medium,#777);font-size:var(--ldp-font-ui,12px);} .ldp-sub-loading{font-size:var(--ldp-font-ui,12px);opacity:.5;margin-left:22px;padding-left:14px; margin-top:2px;display:none;} .ldp-jump-linkbar{display:flex;align-items:center;gap:8px;margin:0 0 6px; font-size:var(--ldp-font-ui,12px);} .ldp-jump-backbar{margin-bottom:8px;} .ldp-jump-linkbar .ldp-jump-parent,.ldp-jump-back{color:var(--tertiary,#08c);font-weight:var(--ldp-reader-font-weight-base,400);} .ldp-jump-highlight{position:relative;border-radius:var(--ldp-jump-highlight-radius,10px);} .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){ animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);} .ldp-post[data-post-number="1"].ldp-jump-highlight{isolation:isolate;} .ldp-post[data-post-number="1"].ldp-jump-highlight::before{content:"";position:absolute;z-index:-1;left:0;right:0;top:0; height:var(--ldp-first-post-jump-highlight-height,100%);border-radius:var(--ldp-jump-highlight-radius,10px);pointer-events:none; animation:ldp-jump-pulse var(--ldp-jump-highlight-duration,1250ms) ease-in-out var(--ldp-jump-highlight-count,2);} @keyframes ldp-jump-pulse{ 0%{background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent); box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);} 100%{background:transparent;box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) transparent;} } @media (prefers-reduced-motion:reduce){ .ldp-jump-highlight:not(.ldp-post[data-post-number="1"]){animation:none;background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent); box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);} .ldp-post[data-post-number="1"].ldp-jump-highlight::before{animation:none; background:color-mix(in srgb,var(--ldp-jump-highlight-color,#0888cc) 34%,transparent); box-shadow:inset 0 0 0 var(--ldp-jump-highlight-border-width,1px) var(--ldp-jump-highlight-color,#0888cc);} } .ldp-nested-toggle{display:none;align-items:center;height:24px;margin:2px 0;line-height:1;vertical-align:middle;color:var(--tertiary,#08c);font-weight:600;font-size:var(--ldp-font-ui,12px);} .ldp-nested-esc-hint{display:inline-flex;align-items:center;height:24px;margin-left:8px;line-height:1;color:var(--primary-medium,#777); font-size:var(--ldp-font-sm,11px);font-weight:500;white-space:nowrap;vertical-align:middle;} .ldp-reply-collapsible > .ldp-nested-toggle,.ldp-hidden-collapsible > .ldp-nested-toggle{display:inline-flex;} .ldp-nested-preview > .ldp-nested-toggle{display:none;} .ldp-post.ldp-nested-collapsed{display:flex;align-items:center;gap:8px;padding:4px 16px;font-size:var(--ldp-font-ui,12px);cursor:pointer;} .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle{display:inline-flex;flex:none;margin:0;font-size:var(--ldp-font-ui,12px);} .ldp-topic-edit-label-remove .ldp-icon,.ldp-only-op-toggle .ldp-icon,.ldp-reader-profile-indicator .ldp-icon, .ldp-color-reset .ldp-icon,.ldp-boost-item-action .ldp-icon,.ldp-nested-toggle .ldp-icon, .ldp-post.ldp-nested-collapsed > .ldp-nested-toggle .ldp-icon{width:12px;height:12px;} .ldp-post.ldp-nested-collapsed > .ldp-post-head{flex:1;min-width:0;margin:0;gap:6px;white-space:nowrap;overflow:hidden;} .ldp-post.ldp-nested-collapsed .ldp-avatar{width:20px;height:20px;} .ldp-post.ldp-nested-collapsed .ldp-author, .ldp-post.ldp-nested-collapsed .ldp-user, .ldp-post.ldp-nested-collapsed .ldp-time{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-post.ldp-nested-collapsed > .ldp-post-head:has(.ldp-time:hover), .ldp-post.ldp-nested-collapsed .ldp-time:hover{overflow:visible;} .ldp-post.ldp-nested-collapsed .ldp-new-user-badge{min-height:18px;padding:1px 5px 1px 4px;gap:3px;font-size:var(--ldp-font-xs,10px);} .ldp-post.ldp-nested-collapsed .ldp-new-user-badge .ldp-icon{width:11px;height:11px;} .ldp-post.ldp-nested-collapsed .ldp-user, .ldp-post.ldp-nested-collapsed .ldp-time, .ldp-post.ldp-nested-collapsed .ldp-floor:not(.ldp-jump-parent):not(.ldp-jump-self){font-size:var(--ldp-font-sm,11px);opacity:.55;} .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-parent, .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-jump-self{font-size:var(--ldp-font-sm,11px);} .ldp-post.ldp-nested-collapsed .ldp-floor.ldp-body-floor{ background:color-mix(in srgb,var(--primary,#222) 3%,transparent);} .ldp-post.ldp-nested-collapsed .ldp-op, .ldp-post.ldp-nested-collapsed .ldp-me{font-size:var(--ldp-font-xs,10px);padding:0 4px;} .ldp-post.ldp-nested-collapsed > .ldp-jump-linkbar, .ldp-post.ldp-nested-collapsed > .ldp-content, .ldp-post.ldp-nested-collapsed > .ldp-boost-list, .ldp-post.ldp-nested-collapsed > .ldp-reactions, .ldp-post.ldp-nested-collapsed > .ldp-special-badges, .ldp-post.ldp-nested-collapsed > .ldp-special-notice, .ldp-post.ldp-nested-collapsed > .ldp-system-action, .ldp-post.ldp-nested-collapsed > .ldp-collapse-replies, .ldp-post.ldp-nested-collapsed > .ldp-children, .ldp-post.ldp-nested-collapsed > .ldp-sub-loading, .ldp-post.ldp-nested-collapsed > .ldp-sub-actions{display:none!important;} /* 下拉加载评论时的底部加载提示 */ .ldp-loading-tip{padding:14px 0;text-align:center;font-size:var(--ldp-font-base,13px); color:var(--primary-medium,#888);display:none;user-select:none;} .ldp-loading-tip.show{display:block;} .ldp-loading-tip .ldp-tip-icon{display:inline-block;margin-right:6px; animation:ldp-spin .9s linear infinite;} .ldp-end-tip{display:none;padding:6px 0;text-align:center; color:var(--primary-medium,#888);font-size:var(--ldp-font-base,13px);line-height:1.2;user-select:none;} .ldp-end-tip.show{display:block;white-space:nowrap;} @keyframes ldp-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}} @container ldp-reader (max-width:900px){ .ldp-reader-main,.ldp-fullpage .ldp-reader-main{grid-template-columns: minmax(0,min(var(--ldp-layout-left,10%),48px)) minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),24px)) clamp(52px,var(--ldp-layout-timeline,10%),72px) minmax(0,min(var(--ldp-layout-right,5%),48px));} .ldp-fullpage .ldp-header{--ldp-header-logo-inset:max(12px,calc(min(var(--ldp-layout-left,20%),48px) + 12px)); --ldp-header-reserved-right:max(148px,calc(min(var(--ldp-layout-right,8%),48px) + 12px));} .ldp-body > *{padding-left:16px;padding-right:16px;} .ldp-topic-timeline,.ldp-fullpage .ldp-topic-timeline{margin:18px 0;padding-inline:2px;} .ldp-topic-timeline-count,.ldp-topic-timeline-preview{left:24px;max-width:calc(100% - 24px);} } @media (max-width:700px){ .ldp-overlay{align-items:stretch;justify-content:stretch;background:var(--secondary,#fff);pointer-events:auto;} .ldp-modal{width:100vw;height:100vh;border-radius:0;box-shadow:none;} .ldp-reader-window-capsule,.ldp-reader-resize-handle{display:none!important;} .ldp-settings-popover{--ldp-settings-frozen-height:68px;left:8px!important;top:8px!important;right:auto; transform:none!important;grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr); width:calc(100vw - 16px);height:calc(100dvh - 16px);max-width:none;max-height:none;border-radius:10px;} .ldp-settings-tabs{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center; width:100%;height:auto;min-height:0;padding:0 44px 0 8px;border-right:0; border-bottom:var(--ldp-divider-line-width,1px) solid var(--ldp-divider-line-color,#e5e5e5);} .ldp-settings-brand{display:none;} .ldp-settings-nav-shell{display:contents;} .ldp-settings-nav{min-width:0;min-height:0;flex:none;flex-direction:row;gap:3px;padding:8px 0; overflow-x:auto;overflow-y:hidden;overscroll-behavior-x:contain;scrollbar-width:none;} .ldp-settings-nav-scroll{display:none!important;} .ldp-settings-tab{width:auto;flex:none;gap:6px;padding:7px 9px;white-space:nowrap;} .ldp-settings-tab[data-settings-panel="user"]{margin-right:9px;margin-bottom:0;} .ldp-settings-tab[data-settings-panel="user"]::after{left:auto;right:-5px;top:0;bottom:0;width:1px;height:auto;} .ldp-settings-footer{margin:0;padding:8px 0 8px 8px;border-top:0;} .ldp-settings-theme{grid-template-columns:repeat(3,30px);} .ldp-settings-panel{padding:0 14px 14px;} .ldp-settings-intro{margin:0 -14px;padding:15px 48px 12px 14px;cursor:default;touch-action:auto;} .ldp-settings-intro::before{display:none;} .ldp-settings-close{top:10px;right:10px;} } @container ldp-reader (max-width:700px){ .ldp-reader-history-edge{position:absolute;inset-block:0;z-index:8;width:56px; display:flex;align-items:center;justify-content:center;pointer-events:none;} .ldp-reader-history-edge-back{left:0;} .ldp-reader-history-edge-forward{right:0;} .ldp-reader-history-edge .ldp-reader-history-nav{position:relative;top:auto;left:auto;right:auto; width:44px;height:44px;border-color:transparent;background:transparent;transform:scale(.92);opacity:0; pointer-events:none; transition:opacity 180ms cubic-bezier(.22,1,.36,1),transform 180ms cubic-bezier(.22,1,.36,1);} .ldp-reader-history-edge.is-active .ldp-reader-history-nav, .ldp-history-buttons-always-visible .ldp-reader-history-edge .ldp-reader-history-nav, .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{opacity:.35;transform:scale(1);pointer-events:auto;} .ldp-reader-history-edge .ldp-reader-history-nav::before{content:"";position:absolute;inset:5px; border:1px solid var(--primary-low,#ccc);border-radius:50%;background:var(--secondary,#fff);} .ldp-reader-history-edge .ldp-reader-history-nav .ldp-icon{position:relative;z-index:1;} .ldp-reader-history-edge .ldp-reader-history-nav:hover{border-color:transparent;background:transparent;} .ldp-reader-history-edge .ldp-reader-history-nav:hover::before{ border-color:var(--primary-medium,#999);background:var(--primary-very-low,#f0f0f0);} .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible{outline:none;} .ldp-reader-history-edge .ldp-reader-history-nav:focus-visible::before{ outline:2px solid var(--tertiary,#47855f);outline-offset:2px;} .ldp-reader-history-edge .ldp-reader-history-nav:active{opacity:1;transform:scale(.96);} .ldp-header{--ldp-header-logo-inset:10px;--ldp-header-reserved-right:138px; --ldp-home-logo-size:28px;--ldp-home-logo-box-size:32px; display:grid;grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;grid-template-rows:auto auto; align-items:center;gap:2px 8px;min-height:66px;} .ldp-home-logo{grid-column:1;grid-row:1 / 3;align-self:center; width:var(--ldp-home-logo-box-size);height:var(--ldp-home-logo-box-size);} .ldp-title-wrap{display:contents;} .ldp-topic-edit-layer{padding:10px;} .ldp-reader-action-body.ldp-topic-edit-body{grid-template-columns:minmax(0,1fr);} .ldp-topic-edit-title,.ldp-topic-edit-status{grid-column:1;} .ldp-topic-edit-category-menu{min-width:0;} .ldp-topic-edit-category-options{max-height:min(220px,32vh);} .ldp-title{grid-column:2 / -1;grid-row:1;min-width:0;display:flex;align-items:center; font-size:var(--ldp-font-md,14px);line-height:1.2;} .ldp-title-jump{min-width:0;display:block;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-title-actions{--ldp-header-action-gap:6px;top:6px;right:8px;} .ldp-title-actions :is(.ldp-topic-edit-trigger,.ldp-open){width:26px;height:26px;flex-basis:26px;padding:3px;} .ldp-title-subline{grid-column:1 / 3;grid-row:2;min-width:0;padding-left:calc(var(--ldp-home-logo-box-size) + 8px); align-items:start;justify-items:start;gap:4px;overflow:visible;white-space:normal;text-align:left;} .ldp-meta{font-size:var(--ldp-font-xs,10px);overflow:visible;text-overflow:clip;white-space:normal;} .ldp-title-topic-row{min-width:0;overflow:visible;} .ldp-topic-tags{flex-wrap:wrap;min-width:0;overflow:visible;} .ldp-topic-tag{max-width:72px;font-size:var(--ldp-font-xs,10px);} .ldp-topic-category{max-width:100%;} .ldp-head-btns{grid-column:3;grid-row:2;min-width:0;gap:3px;justify-content:flex-end;margin-top:0;padding-top:8px;} .ldp-layout-toggle{display:none;} .ldp-layout-toggle,.ldp-notifications-toggle,.ldp-history-toggle,.ldp-bookmarks-toggle,.ldp-settings-toggle{ width:26px;height:26px;padding:3px;} .ldp-reader-refresh.ldp-icon-btn,.ldp-close.ldp-icon-btn{width:26px;height:26px;padding:2px;} .ldp-reader-main,.ldp-fullpage .ldp-reader-main{grid-template-columns: minmax(0,min(var(--ldp-layout-left,10%),20px)) minmax(0,1fr) minmax(0,min(var(--ldp-layout-gap,3%),12px)) clamp(44px,var(--ldp-layout-timeline,12%),52px) minmax(0,min(var(--ldp-layout-right,7%),20px));} .ldp-body > *{padding-left:14px;padding-right:14px;} .ldp-topic-timeline,.ldp-fullpage .ldp-topic-timeline{grid-column:4;width:100%;margin:12px 0;padding:0;justify-self:stretch;} .ldp-topic-timeline-date,.ldp-topic-timeline-relative{padding:0;font-size:var(--ldp-font-micro,9px);line-height:1.2;} .ldp-topic-timeline-footer{gap:6px;} .ldp-topic-timeline-top{width:20px;height:20px;} .ldp-topic-timeline-top .ldp-icon{width:12px;height:12px;} .ldp-topic-timeline-track::before{left:5px;} .ldp-topic-timeline-cursor{left:14px;min-width:34px;height:68px;} .ldp-topic-timeline-cursor > span{transform:translate3d(var(--ldp-lens-shift-x,0),calc(-50% + var(--ldp-lens-offset,0) * 13px),0) scale(var(--ldp-lens-scale,1));font-size:13px;} .ldp-topic-timeline-thumb{left:1px;} .ldp-topic-timeline-count, .ldp-topic-timeline-preview{left:14px;max-width:calc(100% - 14px);font-size:var(--ldp-font-micro,9px);gap:0;} .ldp-layout-toggle span,.ldp-notifications-toggle > span,.ldp-history-toggle > span,.ldp-bookmarks-toggle > span,.ldp-settings-toggle span{display:none;} } @container ldp-reader (max-width:480px){ .ldp-time[data-exact-time]::after{right:0;bottom:auto;left:auto;top:calc(100% + 6px);box-sizing:border-box; transform:none;padding:4px 7px;border:0;border-radius:7px;background:rgba(28,28,28,.86);color:#fff; box-shadow:0 2px 6px rgba(0,0,0,.18);font-size:var(--ldp-font-ui,12px);font-weight:500;line-height:1.35;opacity:1;} .ldp-topic-footer-actions{box-sizing:border-box;width:100%;gap:2px;padding-inline:2px; justify-content:space-between;overflow:visible;} .ldp-topic-footer-actions > *{flex:none;} .ldp-topic-footer-actions :is(.ldp-topic-footer-button,.ldp-topic-footer-link,.ldp-topic-notification){ width:30px;height:30px;padding:5px;} .ldp-topic-footer-actions .ldp-topic-shared-issue{width:44px;min-width:44px;gap:2px;padding-inline:4px;} .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-label{display:none;} .ldp-topic-footer-actions .ldp-topic-footer-button.ldp-topic-shared-issue > .ldp-topic-shared-issue-count{ margin-left:0;font-size:var(--ldp-font-micro,9px);} .ldp-topic-footer-actions .ldp-topic-footer-separator{display:none;} .ldp-post-head{flex-wrap:nowrap;gap:6px;} .ldp-post-head > .ldp-author{min-width:3ch;max-width:34%;flex:1 1 auto;} .ldp-post-head > .ldp-user{min-width:0;max-width:26%;flex:1 1 auto;} .ldp-floor-preview > .ldp-post-head{padding-right:44px;gap:6px;} .ldp-floor-preview-close-hint{display:none;} .ldp-floor-preview-close{right:6px;width:28px;padding:0;} .ldp-header{--ldp-header-logo-inset:8px;--ldp-header-reserved-right:8px; grid-template-columns:var(--ldp-home-logo-box-size) minmax(0,1fr) auto;grid-template-rows:32px 28px 22px; gap:2px 8px;min-height:96px;padding:5px 8px;} .ldp-home-logo{grid-column:1;grid-row:1 / 3;} .ldp-header .ldp-title{grid-column:2 / -1;grid-row:1;padding-right:124px;} .ldp-title-subline{display:contents;} .ldp-meta-row{grid-column:2;grid-row:2;display:block;min-width:0;width:100%;overflow:hidden;white-space:nowrap;} .ldp-meta{display:flex;min-width:0;width:100%;align-items:center;overflow:hidden;white-space:nowrap;} .ldp-meta-stats{display:block;min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-meta-owner{display:inline-flex;min-width:0;flex:0 1 auto;overflow:hidden;} .ldp-meta-owner-copy{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .ldp-title-topic-row{grid-column:2 / -1;grid-row:3;display:flex!important;min-width:0;width:100%; flex-wrap:nowrap;align-items:center;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;} .ldp-title-topic-row::-webkit-scrollbar{display:none;} .ldp-topic-tags{min-width:max-content;flex-wrap:nowrap;overflow:visible;} .ldp-head-btns{grid-column:3;grid-row:2;position:static;right:auto;gap:3px; margin:0;padding:0;align-self:center;justify-self:end;} .ldp-title-actions{top:7px;right:8px;} } @media (max-height:560px){ .ldp-header{padding-top:4px;padding-bottom:4px;} .ldp-settings-popover{--ldp-settings-frozen-height:60px;height:min(520px,calc(100dvh - 16px)); max-height:calc(100dvh - 16px);} .ldp-settings-brand-logo{width:38px;height:38px;} .ldp-settings-nav{padding-top:6px;} .ldp-settings-tab{padding-top:7px;padding-bottom:7px;} .ldp-settings-intro{padding-top:12px;padding-bottom:9px;} .ldp-topic-timeline,.ldp-fullpage .ldp-topic-timeline{margin-top:12px;margin-bottom:12px;} } @media (prefers-reduced-motion:reduce){ @container ldp-reader (max-width:700px){ .ldp-reader-history-edge .ldp-reader-history-nav{transition:none;} } } `; function ensureReaderStyleMounted() { const target = document.head || document.documentElement; if (!target) return; if (style.parentNode !== target || target.lastElementChild !== style) target.appendChild(style); } ensureReaderStyleMounted(); function readerHostStyleSources() { if (!document.head) return []; return Array.from(document.head.querySelectorAll('link[rel~="stylesheet"], style')).filter((source) => source !== style && source.dataset.ldpReaderShadow !== '1' && source.dataset.ldpHostStyle !== '1' ); } function readerHostStyleLoadsAfterReader(source) { return !!(source && style.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING); } function insertReaderHostStyleClone(source, root, beforeNode) { if (!source || !root) return; const previous = READER_HOST_STYLE_CLONES.get(source); const clone = source.cloneNode(true); clone.removeAttribute('id'); clone.dataset.ldpHostStyle = '1'; if (previous && previous.isConnected) { previous.replaceWith(clone); } else { const sources = readerHostStyleSources(); const sourceIndex = sources.indexOf(source); const loadsAfterReader = readerHostStyleLoadsAfterReader(source); const nextClone = sources.slice(sourceIndex + 1) .filter((item) => readerHostStyleLoadsAfterReader(item) === loadsAfterReader) .map((item) => READER_HOST_STYLE_CLONES.get(item)) .find((item) => item && item.isConnected); const shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]'); const fallback = loadsAfterReader ? (shadowGuards || beforeNode?.nextSibling) : beforeNode; root.insertBefore(clone, nextClone || fallback || root.firstChild); } READER_HOST_STYLE_CLONES.set(source, clone); } function cloneReaderHostStyles(root, beforeNode) { if (!root || !document.head) return; root.querySelectorAll('[data-ldp-host-style]').forEach((clone) => clone.remove()); READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove()); READER_HOST_STYLE_CLONES.clear(); readerHostStyleSources().forEach((source) => insertReaderHostStyleClone(source, root, beforeNode)); if (READER_HOST_STYLE_OBSERVER) return; READER_HOST_STYLE_OBSERVER = new MutationObserver((mutations) => { const changed = new Set(); mutations.forEach((mutation) => { if (mutation.type === 'attributes') { changed.add(mutation.target); return; } const styleOwner = mutation.target.nodeType === Node.ELEMENT_NODE && mutation.target.matches('style') ? mutation.target : mutation.target.parentElement && mutation.target.parentElement.closest('style'); if (styleOwner) changed.add(styleOwner); mutation.removedNodes.forEach((node) => { const removed = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"], style') ? [node] : (node.querySelectorAll ? Array.from(node.querySelectorAll('link[rel~="stylesheet"], style')) : []); removed.forEach((source) => { READER_HOST_STYLE_CLONES.get(source)?.remove(); READER_HOST_STYLE_CLONES.delete(source); }); }); mutation.addedNodes.forEach((node) => { const added = node.nodeType === Node.ELEMENT_NODE && node.matches('link[rel~="stylesheet"], style') ? [node] : (node.querySelectorAll ? Array.from(node.querySelectorAll('link[rel~="stylesheet"], style')) : []); added.forEach((source) => changed.add(source)); }); }); const shadowStyle = READER_PORTAL_ROOT && READER_PORTAL_ROOT.querySelector('style[data-ldp-reader-shadow]'); if (!shadowStyle) return; changed.forEach((source) => { if (source === style) return; if (!source.isConnected || !source.matches('link[rel~="stylesheet"], style')) { READER_HOST_STYLE_CLONES.get(source)?.remove(); READER_HOST_STYLE_CLONES.delete(source); return; } insertReaderHostStyleClone(source, READER_PORTAL_ROOT, shadowStyle); }); }); READER_HOST_STYLE_OBSERVER.observe(document.head, { subtree: true, childList: true, attributes: true, attributeFilter: ['href', 'rel', 'media', 'disabled'], }); } function mirrorReaderHostAttributes(source, target, marker) { if (!source || !target) return; const sourceNames = new Set(source.getAttributeNames().filter((name) => name !== 'style')); target.getAttributeNames().forEach((name) => { if (name !== marker && !sourceNames.has(name)) target.removeAttribute(name); }); sourceNames.forEach((name) => target.setAttribute(name, source.getAttribute(name))); target.setAttribute(marker, '1'); } function syncReaderHostContextAttributes() { mirrorReaderHostAttributes(document.documentElement, READER_PORTAL_CONTEXT_HTML, 'data-ldp-shadow-context-html'); mirrorReaderHostAttributes(document.body, READER_PORTAL_CONTEXT_BODY, 'data-ldp-shadow-context-body'); } function destroyReaderPortal() { if (READER_HOST_STYLE_OBSERVER) READER_HOST_STYLE_OBSERVER.disconnect(); if (READER_HOST_CONTEXT_OBSERVER) READER_HOST_CONTEXT_OBSERVER.disconnect(); READER_HOST_STYLE_OBSERVER = null; READER_HOST_CONTEXT_OBSERVER = null; if (READER_ICON_TOOLTIP_CLEANUP) READER_ICON_TOOLTIP_CLEANUP(); READER_ICON_TOOLTIP_CLEANUP = null; READER_HOST_STYLE_CLONES.forEach((clone) => clone.remove()); READER_HOST_STYLE_CLONES.clear(); const host = READER_PORTAL_HOST; const root = READER_PORTAL_ROOT; if (host) releasePersistentAvatarImages(host); if (root) root.replaceChildren(); READER_PORTAL_HOST = null; READER_PORTAL_ROOT = null; READER_PORTAL_CONTEXT_HTML = null; READER_PORTAL_CONTEXT_BODY = null; if (host) host.remove(); } function clearReaderResourceObjectUrlsIfIdle() { if (CURRENT_OVERLAY && CURRENT_OVERLAY.isConnected) return false; [AVATAR_OBJECT_URL_CACHE, EMOJI_IMAGE_OBJECT_URL_CACHE, LIGHTBOX_IMAGE_OBJECT_URL_CACHE] .forEach(clearResourceObjectUrlCache); return true; } function releaseReaderPortalIfIdle() { if (CURRENT_OVERLAY && CURRENT_OVERLAY.isConnected) return false; destroyReaderPortal(); const pendingResourceRequests = [ ...AVATAR_RESOURCE_REQUESTS.values(), ...EMOJI_IMAGE_RESOURCE_REQUESTS.values(), ...LIGHTBOX_IMAGE_RESOURCE_REQUESTS.values(), ]; clearReaderResourceObjectUrlsIfIdle(); if (pendingResourceRequests.length) { void Promise.allSettled(pendingResourceRequests).then(clearReaderResourceObjectUrlsIfIdle); } return true; } function ensureReaderHostContext(root) { if (!root || !document.documentElement || !document.body) return null; let contextHtml = root.querySelector(':scope > html[data-ldp-shadow-context-html]'); if (!contextHtml) { contextHtml = document.createElement('html'); contextHtml.dataset.ldpShadowContextHtml = '1'; root.appendChild(contextHtml); } let contextBody = contextHtml.querySelector(':scope > body[data-ldp-shadow-context-body]'); if (!contextBody) { contextBody = document.createElement('body'); contextBody.dataset.ldpShadowContextBody = '1'; contextHtml.appendChild(contextBody); } READER_PORTAL_CONTEXT_HTML = contextHtml; READER_PORTAL_CONTEXT_BODY = contextBody; syncReaderHostContextAttributes(); Array.from(root.childNodes).forEach((node) => { if (node === contextHtml || node.nodeType !== Node.ELEMENT_NODE) return; if (node.matches('style, link[rel~="stylesheet"]')) return; contextBody.appendChild(node); }); if (!READER_HOST_CONTEXT_OBSERVER) { READER_HOST_CONTEXT_OBSERVER = new MutationObserver((mutations) => { if (mutations.some((mutation) => mutation.attributeName !== 'style')) syncReaderHostContextAttributes(); }); READER_HOST_CONTEXT_OBSERVER.observe(document.documentElement, { attributes: true }); READER_HOST_CONTEXT_OBSERVER.observe(document.body, { attributes: true }); } return contextBody; } function ensureReaderPortal() { if (READER_PORTAL_HOST && READER_PORTAL_HOST.isConnected && READER_PORTAL_ROOT) { return READER_PORTAL_ROOT; } if (!document.documentElement || !document.body) return null; if (READER_PORTAL_HOST || READER_PORTAL_ROOT) destroyReaderPortal(); let host = document.documentElement.querySelector(':scope > .ldp-reader-portal-host'); if (!host) { host = document.createElement('div'); host.className = 'ldp-reader-portal-host sciapp-ldp-owned'; document.documentElement.appendChild(host); } host.classList.add('sciapp-ldp-owned'); const root = host.shadowRoot || host.attachShadow({ mode: 'open' }); let shadowStyle = root.querySelector('style[data-ldp-reader-shadow]'); if (!shadowStyle) { shadowStyle = style.cloneNode(true); shadowStyle.dataset.ldpReaderShadow = '1'; root.prepend(shadowStyle); } let shadowGuards = root.querySelector('style[data-ldp-reader-shadow-guards]'); if (!shadowGuards) { shadowGuards = document.createElement('style'); shadowGuards.dataset.ldpReaderShadowGuards = '1'; shadowStyle.after(shadowGuards); } shadowGuards.textContent = ` [hidden]{display:none!important;} html[data-ldp-shadow-context-html],body[data-ldp-shadow-context-body]{display:contents!important;} `; cloneReaderHostStyles(root, shadowStyle); READER_PORTAL_CONTEXT_BODY = ensureReaderHostContext(root); READER_PORTAL_HOST = host; READER_PORTAL_ROOT = root; syncReaderFontRenderingState(); return root; } function appendReaderPortalNode(node) { const root = ensureReaderPortal(); if (!root) throw new Error('reader portal unavailable'); (READER_PORTAL_CONTEXT_BODY || root).appendChild(node); applyReaderThemeVariables(node); return node; } function readerPortalQuery(selector) { return (READER_PORTAL_ROOT && READER_PORTAL_ROOT.querySelector(selector)) || document.querySelector(selector); } function readerPortalQueryAll(selector) { const nodes = READER_PORTAL_ROOT ? Array.from(READER_PORTAL_ROOT.querySelectorAll(selector)) : []; document.querySelectorAll(selector).forEach((node) => { if (!nodes.includes(node)) nodes.push(node); }); return nodes; } function ensureReaderIconTooltip() { const root = ensureReaderPortal(); if (!root || root._ldpReaderIconTooltipReady) return; root._ldpReaderIconTooltipReady = true; const tooltip = document.createElement('div'); tooltip.className = 'ldp-reader-icon-tooltip ldp-transient-surface'; tooltip.setAttribute('role', 'tooltip'); tooltip.hidden = true; (READER_PORTAL_CONTEXT_BODY || root).appendChild(tooltip); let activeControl = null; const hide = () => { activeControl = null; tooltip.hidden = true; tooltip.textContent = ''; }; const hasVisibleText = (control) => Array.from(control.childNodes).some((node) => { if (node.nodeType === Node.TEXT_NODE) return !!String(node.textContent || '').trim(); if (!(node instanceof Element) || node.matches('.ldp-icon,.ldp-logo,.ldp-notification-unread-badge')) return false; const computed = getComputedStyle(node); if (computed.display === 'none' || computed.visibility === 'hidden') return false; return !!String(node.innerText || node.textContent || '').trim(); }); const tooltipControl = (target) => { if (!(target instanceof Element)) return null; const control = target.closest('button,a,[role="button"],[data-ldp-tooltip-label],.ldp-nested-branch-toggle,.ldp-avatar-flair,.ldp-user-card-badge'); const inReaderSurface = control && (control.closest('.ldp-overlay,.ldp-lightbox') || control.closest('.ldp-user-card-fallback,.ldp-avatar-viewer') || control.matches('.ldp-native-reader-trigger')); if (!inReaderSurface || control.hasAttribute('data-tooltip') || control.matches('.ldp-topic-timeline-track,[data-reaction-picker][aria-expanded="true"]')) return null; const isFunctionalButton = control.matches('button,[role="button"],.ldp-nested-branch-toggle'); const isIconOnlyLink = control.matches('a') && control.querySelector('.ldp-icon,.ldp-logo,img') && !hasVisibleText(control); const isNamedCopyTarget = control.matches('.ldp-avatar-flair,.ldp-user-card-badge'); const isNamedTooltipTarget = control.hasAttribute('data-ldp-tooltip-label'); if (!isFunctionalButton && !isIconOnlyLink && !isNamedCopyTarget && !isNamedTooltipTarget) return null; const isTruncatedNarrowTitle = control.matches('.ldp-title-jump') && (control.closest('.ldp-modal')?.getBoundingClientRect().width || 0) <= 480 && control.scrollWidth > control.clientWidth + 1; const label = String(isTruncatedNarrowTitle ? control.textContent : control.getAttribute('aria-label') || control.dataset.ldpTooltipLabel || '').trim(); return label ? { control, label } : null; }; const position = (control, pointerEvent = null) => { const rect = control.getBoundingClientRect(); const tipRect = tooltip.getBoundingClientRect(); const gap = 6; const edge = 8; if (control.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') && pointerEvent && Number.isFinite(pointerEvent.clientX) && Number.isFinite(pointerEvent.clientY)) { const pointerGap = 12; let left = pointerEvent.clientX + pointerGap; if (left + tipRect.width > window.innerWidth - edge) { left = pointerEvent.clientX - tipRect.width - pointerGap; } let top = pointerEvent.clientY + pointerGap; if (top + tipRect.height > window.innerHeight - edge) { top = pointerEvent.clientY - tipRect.height - pointerGap; } tooltip.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge)))}px`; tooltip.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - tipRect.height - edge)))}px`; return; } let left = rect.left + (rect.width - tipRect.width) / 2; left = Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge)); let top = rect.top - tipRect.height - gap; if (top < edge) top = Math.min(window.innerHeight - tipRect.height - edge, rect.bottom + gap); tooltip.style.left = `${Math.round(left)}px`; tooltip.style.top = `${Math.round(Math.max(edge, top))}px`; }; const show = (target, pointerEvent = null, resolvedMatch = null) => { const match = resolvedMatch || tooltipControl(target); if (!match) return; activeControl = match.control; tooltip.textContent = match.label; tooltip.classList.toggle('ldp-reader-history-tooltip', match.control.matches('.ldp-reader-history-nav')); tooltip.classList.toggle('ldp-connect-help-tooltip', match.control.matches('.ldp-connect-metric')); tooltip.hidden = false; position(match.control, pointerEvent); }; const keepOpen = (control) => control && (control.matches(':hover') || control.matches(':focus-visible')); root.addEventListener('ldp-tooltip-refresh', (event) => { if (!(event.target instanceof Element)) return; const control = event.target; if (activeControl && !activeControl.isConnected) hide(); const match = tooltipControl(control); if (!match || control.hidden || !keepOpen(control)) { if (control === activeControl) hide(); return; } show(control, null, match); }); root.addEventListener('click', (event) => { const copyTarget = event.target instanceof Element && event.target.closest('.ldp-avatar-flair,.ldp-user-card-badge'); if (!copyTarget) return; event.preventDefault(); event.stopPropagation(); const label = String(copyTarget.dataset.ldpTooltipLabel || copyTarget.getAttribute('aria-label') || '').trim(); if (!label) return; const showCopyState = (message) => { copyTarget.dataset.ldpTooltipLabel = message; copyTarget.setAttribute('aria-label', message); copyTarget.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); setTimeout(() => { if (!copyTarget.isConnected || copyTarget.dataset.ldpTooltipLabel !== message) return; copyTarget.dataset.ldpTooltipLabel = label; copyTarget.setAttribute('aria-label', label); copyTarget.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }, 900); }; void writeSelectionQuoteToClipboard(label) .then(() => showCopyState('已复制')) .catch(() => showCopyState('复制失败')); }, true); root.addEventListener('pointerover', (event) => { const match = tooltipControl(event.target); if (!match || (event.relatedTarget instanceof Node && match.control.contains(event.relatedTarget))) return; show(match.control, event, match); }); root.addEventListener('pointerdown', (event) => { if (event.target instanceof Element && event.target.closest('.ldp-reader-drag-handle')) hide(); }, true); root.addEventListener('pointermove', (event) => { if (!activeControl?.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') || !(event.target instanceof Node) || !activeControl.contains(event.target)) return; position(activeControl, event); }); root.addEventListener('pointerout', (event) => { if (!activeControl || (event.relatedTarget instanceof Node && activeControl.contains(event.relatedTarget))) return; if (!activeControl.matches(':focus-visible')) hide(); }); root.addEventListener('focusin', (event) => show(event.target)); root.addEventListener('focusout', () => { queueMicrotask(() => { if (!keepOpen(activeControl)) hide(); }); }); root.addEventListener('scroll', () => { const hoveredHistoryControl = root.querySelector('.ldp-reader-history-nav:hover'); if (hoveredHistoryControl) show(hoveredHistoryControl); else hide(); }, true); window.addEventListener('resize', hide); READER_ICON_TOOLTIP_CLEANUP = () => window.removeEventListener('resize', hide); } function readerActiveElement() { let active = document.activeElement; while (active && active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } function readerSelection() { const candidates = []; const addCandidate = (selection) => { if (selection && !candidates.includes(selection)) candidates.push(selection); }; if (READER_PORTAL_ROOT && typeof READER_PORTAL_ROOT.getSelection === 'function') { try { addCandidate(READER_PORTAL_ROOT.getSelection()); } catch (error) {} } try { addCandidate(window.getSelection()); } catch (error) {} return candidates.find((selection) => selection.rangeCount > 0 && !selection.isCollapsed) || candidates.find((selection) => selection.rangeCount > 0) || candidates[0] || null; } function eventPathClosest(event, selector) { const path = event && typeof event.composedPath === 'function' ? event.composedPath() : []; const matched = path.find((node) => node && node.nodeType === Node.ELEMENT_NODE && node.matches(selector)); if (matched) return matched; return event && event.target && event.target.closest ? event.target.closest(selector) : null; } function eventPathIncludes(event, node) { if (!event || !node) return false; const path = typeof event.composedPath === 'function' ? event.composedPath() : []; return path.includes(node) || node.contains(event.target); } function containFloatingSurfaceWheel(surface, event) { event.stopPropagation(); const target = event.target instanceof Element ? event.target : null; const lineScale = 40; const deltaX = event.deltaMode === 1 ? event.deltaX * lineScale : event.deltaMode === 2 ? event.deltaX * surface.clientWidth : event.deltaX; const deltaY = event.deltaMode === 1 ? event.deltaY * lineScale : event.deltaMode === 2 ? event.deltaY * surface.clientHeight : event.deltaY; if (!target || !surface.contains(target)) { event.preventDefault(); return; } let scrollTarget = target; while (scrollTarget && surface.contains(scrollTarget)) { const style = getComputedStyle(scrollTarget); const maxScrollLeft = scrollTarget.scrollWidth - scrollTarget.clientWidth; const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight; const canScrollX = deltaX && maxScrollLeft > 1 && /(auto|scroll|overlay)/.test(style.overflowX) && (deltaX < 0 ? scrollTarget.scrollLeft > 0 : scrollTarget.scrollLeft < maxScrollLeft - 1); const canScrollY = deltaY && maxScrollTop > 1 && /(auto|scroll|overlay)/.test(style.overflowY) && (deltaY < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1); if (canScrollX || canScrollY) return; if (scrollTarget === surface) break; scrollTarget = scrollTarget.parentElement; } if (deltaX || deltaY) event.preventDefault(); } function bindFloatingSurfaceWheel(surface) { if (!surface || surface._ldpFloatingSurfaceWheelBound) return; surface._ldpFloatingSurfaceWheelBound = true; surface.addEventListener('wheel', (event) => containFloatingSurfaceWheel(surface, event), { passive: false }); } function addTrackedEventListener(bindings, target, type, listener, options) { target.addEventListener(type, listener, options); bindings.push([target, type, listener, options]); } function removeTrackedEventListeners(bindings) { bindings.forEach(([target, type, listener, options]) => target.removeEventListener(type, listener, options)); bindings.length = 0; } function syncTabSelection(tabs, dataKey, selectedValue, roving = false) { tabs.forEach((tab) => { const active = tab.dataset[dataKey] === selectedValue; tab.classList.toggle('active', active); tab.setAttribute('aria-selected', String(active)); if (roving) tab.tabIndex = active ? 0 : -1; }); } function bindReaderPanelSearch(input, clearButton, update, delay = 0) { let pending = 0; const cancel = () => { if (!pending) return; if (delay) clearTimeout(pending); else cancelAnimationFrame(pending); pending = 0; }; const commit = () => { pending = 0; update(input.value.trim()); }; input.addEventListener('input', () => { clearButton.hidden = !input.value; cancel(); pending = delay ? setTimeout(commit, delay) : requestAnimationFrame(commit); }); clearButton.addEventListener('click', () => { cancel(); input.value = ''; clearButton.hidden = true; update(''); input.focus(); }); return cancel; } function bindReaderPanelPager(previousButton, nextButton, state, update) { previousButton.addEventListener('click', () => { if (state.page <= 0) return; state.page--; update(); }); nextButton.addEventListener('click', () => { if (state.hasNext === false) return; state.page++; update(); }); } function toggleReaderPanelSelection(selection, ids) { const fullySelected = ids.length > 0 && ids.every((id) => selection.has(id)); ids.forEach((id) => { if (fullySelected) selection.delete(id); else selection.add(id); }); } function bindReaderPanelSelectionList(list, checkboxSelector, itemSelector, idKey, selection, render) { list.addEventListener('change', (event) => { const checkbox = event.target.closest(checkboxSelector); const id = Number(checkbox?.closest(itemSelector)?.dataset[idKey]); if (!(id > 0)) return; selection[checkbox.checked ? 'add' : 'delete'](id); render(); }); } function bindReaderPanelMultiMode(state, selection, startButton, doneButton, scopeSelect, render, reset = () => {}) { const clear = () => { reset(); selection.clear(); render(); }; startButton.addEventListener('click', () => { state.multi = true; state.scope = 'page'; clear(); }); doneButton.addEventListener('click', () => { state.multi = false; clear(); }); scopeSelect.addEventListener('change', () => { state.scope = scopeSelect.value === 'all' ? 'all' : 'page'; selection.clear(); render(); }); } function handleSearchableMenuKeyDown(event, searchInput, optionsRoot, optionSelector, closeMenu) { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenu(true); return; } if (event.key === 'Enter' && event.target === searchInput) { event.preventDefault(); optionsRoot.querySelector(optionSelector)?.click(); return; } if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; const options = Array.from(optionsRoot.querySelectorAll(optionSelector)); if (!options.length) return; event.preventDefault(); const currentIndex = options.indexOf(document.activeElement); const direction = event.key === 'ArrowDown' ? 1 : -1; const nextIndex = currentIndex < 0 ? (direction > 0 ? 0 : options.length - 1) : (currentIndex + direction + options.length) % options.length; options[nextIndex].focus(); } /* ============ 2. 工具 ============ */ const esc = (s) => String(s ?? '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); const escAttr = (s) => esc(s).replace(/"/g, '"').replace(/'/g, '''); const ICONS = { activity: '<path d="M3 12h4l2-7 4 14 2-7h6"/>', database: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>', image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>', type: '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>', alertTriangle: '<path d="M21.73 18 13.73 4a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>', bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>', headerBell: '<path d="M5 17h14c-1.3-1.6-2-3.8-2-7a5 5 0 0 0-10 0c0 3.2-.7 5.4-2 7Z"/><path d="M10 20a2 2 0 0 0 4 0"/>', bellOff: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18.63 18H4a1 1 0 0 1-.74-1.673C4.59 14.956 6 13.499 6 9c0-.666.108-1.306.308-1.904"/><path d="M10.268 3.061A6 6 0 0 1 18 9a11.525 11.525 0 0 0 .52 3.882"/><path d="m2 2 20 20"/>', bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>', headerBookmark: '<path d="M6.5 4.5h11A1.5 1.5 0 0 1 19 6v14l-7-3.8L5 20V6a1.5 1.5 0 0 1 1.5-1.5Z"/>', check: '<path d="m20 6-11 11-5-5"/>', square: '<rect width="18" height="18" x="3" y="3" rx="2"/>', squareCheck: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>', circleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>', arrowUp: '<path d="M12 17V7"/><path d="M7.5 11.5 12 7l4.5 4.5"/>', chevronDown: '<path d="m6 9 6 6 6-6"/>', chevronUp: '<path d="m18 15-6-6-6 6"/>', chevronRight: '<path d="m9 18 6-6-6-6"/>', download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>', upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>', externalLink: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>', copy: '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>', eyeOff: '<path d="M10.733 5.076A10.744 10.744 0 0 1 21.938 11.65a1 1 0 0 1 0 .696 10.8 10.8 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499A10.75 10.75 0 0 1 2.062 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>', palette: '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" stroke="none"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor" stroke="none"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor" stroke="none"/><path d="M12 2a10 10 0 0 0 0 20c1.1 0 2-.9 2-2 0-.5-.2-1-.6-1.4-.4-.4-.6-.9-.6-1.4a2 2 0 0 1 2-2H17a5 5 0 0 0 5-5C22 5.7 17.5 2 12 2z"/>', award: '<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/>', tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/>', heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7z"/>', hand: '<path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 9.5V6a2 2 0 0 0-4 0v8"/><path d="M6 14v-2a2 2 0 0 0-4 0v2a8 8 0 0 0 8 8h2c5.523 0 10-4.477 10-10V8a2 2 0 0 0-4 0v3"/>', history: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', headerHistory: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v4.7l3.2 1.8"/>', info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>', lock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>', unlock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.9-1"/>', pin: '<path d="M12 17v5"/><path d="M5 17h14"/><path d="m6 3 1 7-3 3h16l-3-3 1-7z"/>', pencil: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>', trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/>', wrench: '<path d="M14.7 6.3a4 4 0 0 0-5-5L7.4 3.6l3 3-3.8 3.8-3-3-2.3 2.3a4 4 0 0 0 5 5L15.6 24l4-4-8.7-9.3 3.8-4.4Z"/>', loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', maximize2: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="M9 21H3v-6"/><path d="m3 21 7-7"/>', layoutGrid: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/><path d="M3 12h18"/>', panelLeft: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>', panelRight: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/>', floatingWindow: '<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M4 9h16"/><path d="M7 7h.01"/><path d="M10 7h.01"/>', messageSquare: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>', minus: '<path d="M5 12h14"/>', minimize2: '<path d="M4 14h6v6"/><path d="m10 14-7 7"/><path d="M20 10h-6V4"/><path d="m14 10 7-7"/>', cornerDownRight: '<path d="m15 10 4 4-4 4"/><path d="M5 4v7a3 3 0 0 0 3 3h11"/>', reply: '<path d="m9 17-5-5 5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>', plus: '<path d="M12 5v14"/><path d="M5 12h14"/>', rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>', sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.42 1.42"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>', moon: '<path d="M20.99 12.8A9 9 0 1 1 11.2 3.01 7 7 0 0 0 20.99 12.8Z"/>', monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>', settings: '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831 2.34 2.34 0 0 1 2.33-4.033 2.34 2.34 0 0 0 3.32-1.915z"/><circle cx="12" cy="12" r="3"/>', headerSettings: '<path d="M4 6h5M13 6h7"/><circle cx="11" cy="6" r="2"/><path d="M4 12h10M18 12h2"/><circle cx="16" cy="12" r="2"/><path d="M4 18h2M10 18h10"/><circle cx="8" cy="18" r="2"/>', smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><path d="M9 9h.01"/><path d="M15 9h.01"/>', search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/>', link2: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', lightbulb: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.84.65-1.64 1.35-2.35a6 6 0 1 0-8.88 0c.7.71 1.17 1.51 1.35 2.35Z"/>', list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>', listChecks: '<path d="m3 5 2 2 4-4"/><path d="M11 6h10"/><path d="m3 12 2 2 4-4"/><path d="M11 13h10"/><path d="m3 19 2 2 4-4"/><path d="M11 20h10"/>', rotateCcw: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>', share2: '<path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/>', shieldHalved: '<path d="M12 2 4 5v6c0 5 3.3 9.4 8 11 4.7-1.6 8-6 8-11V5l-8-3Z"/><path d="M12 2 4 5v6c0 5 3.3 9.4 8 11V2Z" fill="currentColor" stroke="none"/>', userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/>', userRound: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>', x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', }; function icon(name, extraClass) { const cls = `ldp-icon ldp-icon-${name}${extraClass ? ` ${extraClass}` : ''}`; return `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ''}</svg>`; } function profileFieldSharingControl(category, field, label) { const ariaLabel = `三种形态共享${label}`; return `<span class="ldp-profile-field-sharing" data-ldp-tooltip-label="${ariaLabel}"> <input type="checkbox" role="switch" data-profile-sharing-category="${category}" data-profile-sharing-field="${field}" aria-label="${ariaLabel}"> <span class="ldp-profile-field-sharing-button" aria-hidden="true">${icon('link2')}</span> </span>`; } const APPEARANCE_SETTING_FIELDS = [ { key: 'accentColor', section: 'interaction', label: '界面强调色', ariaLabel: '界面强调色', normalize: normalizeAccentColor, applyGroup: 'interaction', help: '控制阅读器的主强调色,包括主要按钮、选中态、焦点轮廓、开关开启态、时间轴和操作反馈;不会改变错误、警告、成功和点赞等语义颜色。选择时实时预览,确认后保存。' }, { key: 'linkColor', section: 'interaction', label: '正文链接色', ariaLabel: '正文链接色', normalize: normalizeLinkColor, applyGroup: 'interaction', help: '控制帖子正文中的 URL 链接颜色,与按钮和选中态的界面强调色分开设置;选择时实时预览,确认后保存。' }, { key: 'zebraColor', section: 'zebra', label: '颜色', ariaLabel: '楼层斑马线颜色', normalize: normalizeZebraColor, applyGroup: 'zebra', help: '设置阅读器正文楼层隔行出现的淡色背景,用来更容易分清相邻楼层。选择时实时预览,点击“确认应用”后才保存。' }, { key: 'zebraRadius', section: 'zebra', label: '圆角', ariaLabel: '楼层斑马线圆角', range: [ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP], normalize: (value, fallback = ZEBRA_RADIUS_DEFAULT) => normalizeSteppedValue(value, fallback, ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP), applyGroup: 'zebra', help: `设置阅读器正文斑马线楼层的圆角,${ZEBRA_RADIUS_MIN}px 为直角,最高 ${ZEBRA_RADIUS_MAX}px。拖动时实时预览,点击“确认应用”后才保存。` }, { key: 'listZebraColor', section: 'list-zebra', label: '列表斑马线', ariaLabel: 'Linux DO 列表斑马线颜色', shareLabel: '列表斑马线', normalize: normalizeZebraColor, applyGroup: 'list-zebra', help: '设置 LINUX DO 帖子列表卡片的隔行色调;所选颜色会作为主色与宿主背景适度混合,兼顾辨识度和文字清晰度。选择时实时预览,点击“确认应用”后才保存。' }, { key: 'replyLineColor', section: 'reply-line', label: '颜色', ariaLabel: '回复关系线颜色', normalize: (value) => normalizeHexColor(value, REPLY_LINE_COLOR_DEFAULT), applyGroup: 'reply-line', help: '控制阅读器里的关系线颜色,包括二级回复连线、父楼层预览、时间轴、特殊提示边框和灯箱评论层级线。选择时实时预览,确认后保存。' }, { key: 'replyLineWidth', section: 'reply-line', label: '粗细', ariaLabel: '回复关系线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = REPLY_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback), applyGroup: 'reply-line', help: `控制回复关系线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;二级回复树线、父子回复连接线和相关层级线会同步变化。拖动时实时预览,确认后保存。` }, { key: 'replyLineRadius', section: 'reply-line', label: '圆角', ariaLabel: '父子与二级回复线圆角', shareLabel: '回复关系线圆角', range: [REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP], normalize: normalizeReplyLineRadius, applyGroup: 'reply-line', help: `控制父子回复和二级回复连接线转折处的圆角,${REPLY_LINE_RADIUS_MIN}px 为直角,最高 ${REPLY_LINE_RADIUS_MAX}px。拖动时实时预览,确认后保存。` }, { key: 'quoteLineColor', section: 'quote-line', label: '颜色', ariaLabel: '引用线颜色', normalize: (value) => normalizeHexColor(value, QUOTE_LINE_COLOR_DEFAULT), applyGroup: 'quote-line', help: '控制帖子正文引用块和“引用某楼层”卡片左侧竖线的颜色,只改变引用提示线,不改变引用文字或背景。选择时实时预览,确认后保存。' }, { key: 'quoteLineWidth', section: 'quote-line', label: '粗细', ariaLabel: '引用线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = QUOTE_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback), applyGroup: 'quote-line', help: `控制引用线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;不同引用样式会保留原有粗细层级。拖动时实时预览,确认后保存。` }, { key: 'dividerLineColor', section: 'divider-line', label: '颜色', ariaLabel: '界面分隔线颜色', normalize: (value) => normalizeHexColor(value, DIVIDER_LINE_COLOR_DEFAULT), applyGroup: 'divider-line', help: '控制楼层边界、标题栏、设置与通知面板、嵌入阅读边界和灯箱结构分隔线的颜色;按钮和输入框外框不会跟着改变。选择时实时预览,确认后保存。' }, { key: 'dividerLineWidth', section: 'divider-line', label: '粗细', ariaLabel: '界面分隔线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = DIVIDER_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback), applyGroup: 'divider-line', help: `控制界面结构分隔线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;按钮和输入框外框不会跟着改变。拖动时实时预览,确认后保存。` }, { key: 'floorPreviewColor', section: 'floor-preview', label: '预览颜色', ariaLabel: '父回复插入预览颜色', shareLabel: '父回复预览颜色', normalize: (value) => normalizeHexColor(value, FLOOR_PREVIEW_COLOR_DEFAULT), applyGroup: 'floor-preview', help: '控制父楼层号悬停后插入的预览卡片边框、淡色背景和楼层标记颜色;不改变二级回复关系线。选择时实时预览,确认后保存。' }, { key: 'floorPreviewHeight', section: 'floor-preview', label: '最大高度', ariaLabel: '父回复插入预览最大高度', shareLabel: '父回复预览高度', range: [FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP], normalize: (value, fallback = FLOOR_PREVIEW_HEIGHT_DEFAULT) => normalizeSteppedValue(value, fallback, FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP), applyGroup: 'floor-preview', help: `控制父回复插入预览在长内容时允许占用的最大高度,最低 ${FLOOR_PREVIEW_HEIGHT_MIN}px;内容较短时仍按实际高度显示,并始终受当前窗口可用高度限制。拖动时实时预览,确认后保存。` }, ]; const APPEARANCE_SETTING_GROUPS = [ { id: 'interaction', title: '强调与交互', description: '控制按钮、选中态、焦点、时间轴和正文链接,不改变错误、警告、成功等状态色。', sections: ['interaction'] }, { id: 'background', title: '内容背景', description: '区分相邻楼层和列表卡片,只改变淡色背景,不改变正文文字。', sections: [{ id: 'zebra', title: '楼层斑马线' }, 'list-zebra'] }, { id: 'structure', title: '结构与关系', description: '分别控制回复层级、引用关系和界面边界的颜色与粗细,并可调整回复线转折圆角。', sections: [{ id: 'reply-line', title: '回复关系线' }, { id: 'quote-line', title: '引用线' }, { id: 'divider-line', title: '界面分隔线' }] }, { id: 'floor-preview', title: '父回复插入预览', description: '单独控制悬停父楼层号后插入的预览卡片,不影响回复关系线和其他内容样式。', sections: ['floor-preview'] }, ]; function appearanceSettingClassName(key) { return `ldp-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; } function appearanceSettingRowMarkup(field) { const className = appearanceSettingClassName(field.key); const input = field.range ? `<input class="${className}-range" type="range" min="${field.range[0]}" max="${field.range[1]}" step="${field.range[2]}" aria-label="${field.ariaLabel}">` : `<input class="${className}" type="color" aria-label="${field.ariaLabel}">`; return `<div class="ldp-setting-row ldp-setting-row-has-sharing"> <span class="ldp-setting-label">${field.label}</span> <span class="${field.range ? 'ldp-font-scale-control' : 'ldp-color-control'}"> ${input}<span class="${className}-value"></span> <button class="ldp-color-reset" type="button" data-color-reset="${field.key}">${icon('rotateCcw')}<span>恢复默认</span></button> </span> ${profileFieldSharingControl('appearance', field.key, field.shareLabel || field.ariaLabel)} </div>`; } function appearanceSettingsMarkup(section) { return APPEARANCE_SETTING_FIELDS.filter((field) => field.section === section) .map(appearanceSettingRowMarkup).join(''); } function appearanceSettingGroupMarkup(group) { const sections = group.sections.map((section) => { const { id, title } = typeof section === 'string' ? { id: section } : section; const rows = appearanceSettingsMarkup(id); if (!title) return rows; const titleId = `ldp-${id}-setting-group-title`; return `<div class="ldp-setting-group" role="group" aria-labelledby="${titleId}"> <div class="ldp-setting-group-title" id="${titleId}">${title}</div> ${rows} </div>`; }).join(''); return `<section class="ldp-color-group" aria-labelledby="ldp-color-group-${group.id}"> <header class="ldp-color-group-head"> <h4 class="ldp-color-group-title" id="ldp-color-group-${group.id}">${group.title}</h4> <p class="ldp-color-group-description">${group.description}</p> </header> <div class="ldp-settings-fields">${sections}</div> </section>`; } function jumpHighlightSettingsMarkup() { return JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => { const className = `ldp-flash-${field.key}`; const limits = JUMP_HIGHLIGHT_LIMITS[field.key]; const input = field.type === 'color' ? `<input class="${className}" type="color" aria-label="${field.ariaLabel}">` : `<input class="${className} ldp-flash-range" type="range" min="${limits.min}" max="${limits.max}" step="${limits.step}" aria-label="${field.ariaLabel}">`; return `<label class="ldp-setting-row"> <span class="ldp-setting-label">${field.label}</span> <span class="${field.type === 'color' ? 'ldp-color-control' : 'ldp-flash-range-control'}"> ${input}<${field.type === 'color' ? 'span' : 'output'} class="${className}-value${field.type === 'color' ? '' : ' ldp-flash-value'}"></${field.type === 'color' ? 'span' : 'output'}> </span> </label>`; }).join(''); } function settingsCategoryMarkup(id, title, description, content, contentClass = 'ldp-settings-category-content', descriptionClass = '') { const titleId = `ldp-${id}-title`; return `<section class="ldp-settings-category-group" aria-labelledby="${titleId}"> <header class="ldp-settings-category-head"> <strong id="${titleId}">${title}</strong> <small${descriptionClass ? ` class="${descriptionClass}"` : ''}>${description}</small> </header> <div class="${contentClass}">${content}</div> </section>`; } function otherSettingGroupMarkup(id, title, description, content) { const titleId = `ldp-other-${id}-title`; return `<section class="ldp-other-setting-group" aria-labelledby="${titleId}"> <header class="ldp-other-setting-group-head"> <strong id="${titleId}">${title}</strong> <small>${description}</small> </header> <div class="ldp-other-setting-list">${content}</div> </section>`; } function performanceSettingsMarkup() { return PERFORMANCE_SETTING_GROUPS.map((group) => settingsCategoryMarkup( `performance-${group.id}`, group.title, group.description, group.fields.map((field) => { const limits = PERFORMANCE_LIMITS[field.key]; return `<label class="ldp-setting-row"> <span class="ldp-performance-copy"><strong>${field.title}</strong><small>${field.description}</small></span> <span class="ldp-performance-control"><input type="number" data-performance-key="${field.key}" min="${limits.min}" max="${limits.max}" step="${limits.step}" inputmode="${field.inputMode}"><em>${field.unit}</em></span> </label>`; }).join(''), 'ldp-settings-fields ldp-settings-category-list ldp-performance-fields' )).join(''); } const SETTINGS_PANEL_META = [ ['user', 'userRound', '用户信息', '集中查看当前账号资料、社区统计与 Connect 信任级别升级进度。'], ['image', 'image', '图片设置', '灯箱原图模式统一用于所有显示形态;帖子图片比例可按形态保存。'], ['font', 'type', '字体设置', '字体渲染默认作用于整个增强阅读器,也可扩展到宿主页面;界面、正文和回复的字体外观继续分别设置。'], ['layout', 'layoutGrid', '布局设置', '五区比例可逐项共享;共享项目变化时自动平衡其它区域,始终保持合计 100%。'], ['window', 'floatingWindow', '浮窗设置', '设置桌面浮窗的长宽和左上角坐标;拖动或缩放浮窗后会同步保存,其他显示方式下修改会在切回浮窗后生效。'], ['appearance', 'palette', '外观设置', '每一项外观都可单独共享,其余项目继续由浮窗、全屏和移动分别保存。'], ['flash', 'lightbulb', '闪烁动效', '集中设置跳转楼层时的闪烁提示,以及打开或切换帖子时的等待区域动画。'], ['performance', 'rocket', '性能设置', '先控制“产生多少请求与 DOM”,再控制“请求何时启动”。主楼层取数与楼中楼自动请求属于需求来源;DOM 窗口只负责渲染;共享调度负责排队、双窗口预算、429 退避与恢复探测,但不会抵消过大的预取需求。设置在下次打开阅读器时生效。'], ['resource-monitor', 'monitor', '资源监控', '状态每秒快照,长任务、长帧脚本、DOM 变更、网络和前后台切换按浏览器原始事件记录到毫秒;标签页进入后台后继续取证,计时器被节流或冻结时保留真实空档,不补造样本。仅在本页内存保留最近 10 分钟。', '实时资源监控'], ['request-flow', 'activity', '请求数据', '同源标签页共享阅读器与 LINUX DO 宿主 API 的 10 秒/60 秒请求账本、优先级、活动槽、有效策略和限流状态;宿主请求发出后立即纳入账本,后续阅读器请求主动避让。当前页静态资源只用于网络观测,不消耗 API 配额。下方速率、类型、P95 与详细日志是当前标签页口径,只在本页内存保留最近 15 分钟、最多 1200 条;“全局”执行、排队和窗口来自共享账本。不会记录查询参数、请求正文、Cookie 或响应内容。'], ['other', 'wrench', '其他功能', '按历史导航、帖子打开、回复展示和 Boost 复制分类管理,修改后自动保存在当前浏览器。'], ['cache', 'database', '数据管理', '备份或恢复阅读器设置,并查看、清理当前浏览器保存的本地缓存。'], ['about', 'info', '关于', '专注于长帖阅读、楼层关系和原生社区操作的 LINUX DO 阅读工作台。'], ]; function settingsNavigationMarkup() { return SETTINGS_PANEL_META.map(([id, iconName, title], index) => `<button class="ldp-settings-tab${index ? '' : ' active'}" type="button" data-settings-panel="${id}">${icon(iconName)}<span>${title}</span><span class="ldp-settings-tab-draft-count" aria-hidden="true" hidden></span></button>` ).join(''); } function settingsPanelIntroMarkup(id) { const panel = SETTINGS_PANEL_META.find(([panelId]) => panelId === id); if (!panel) return ''; const extra = id === 'user' ? `<span class="ldp-user-info-last-refresh">最后刷新 · 暂无</span><button class="ldp-user-info-title-refresh" type="button" aria-label="刷新用户信息">${icon('rotateCcw')}</button>` : ''; return `<div class="ldp-settings-intro"><h3 class="ldp-settings-title">${panel[4] || panel[2]}</h3><p class="ldp-settings-description">${panel[3]}</p>${extra}</div>`; } function readerProfileTabsMarkup(category, label) { return `<div class="ldp-layout-profile-tabs" role="tablist" aria-label="${label}模式"> ${READER_PROFILE_KEYS.map((profile) => `<button class="ldp-layout-profile-tab" type="button" role="tab" data-${category}-profile="${profile}">${READER_PROFILE_LABELS[profile]}</button>`).join('')} </div><p class="ldp-reader-profile-routing" data-profile-routing="${category}"></p>`; } function settingsApplyFooterMarkup(statusClass, applyClass, resetLabel = '', resetClass = '') { const resetButton = resetLabel ? `<button class="ldp-layout-reset${resetClass ? ` ${resetClass}` : ''}" type="button">${icon('rotateCcw')}<span>${resetLabel}</span></button>` : ''; return `<div class="ldp-layout-footer"><span class="ldp-layout-total${statusClass ? ` ${statusClass}` : ''}" role="status"></span> <span class="ldp-layout-footer-actions">${resetButton}<button class="ldp-layout-apply${applyClass ? ` ${applyClass}` : ''}" type="button" disabled>${icon('check')}<span>确认应用</span></button></span></div>`; } function readerWindowGeometryMarkup() { return [ ['width', `宽度(最小 ${READER_WINDOW_MIN_WIDTH}px)`, READER_WINDOW_MIN_WIDTH], ['height', `高度(最小 ${READER_WINDOW_MIN_HEIGHT}px)`, READER_WINDOW_MIN_HEIGHT], ['x', '左上角 X', READER_WINDOW_MARGIN], ['y', '左上角 Y', READER_WINDOW_MARGIN], ].map(([key, label, min]) => `<label class="ldp-reader-window-field"><span>${label}</span> <span class="ldp-reader-window-input-wrap"><input class="ldp-reader-window-input ldp-reader-window-${key}" type="number" min="${min}" step="1" inputmode="numeric"><span>px</span></span></label>`).join(''); } function aboutFeaturesMarkup() { return [ ['layoutGrid', '多形态专注阅读', '浮窗、全屏、左右嵌入与移动布局,图片、字体和结构可按场景调整。'], ['image', '完整内容与楼层关系', '楼中楼、引用、时间轴、图片、视频、音频和 Markdown 提示块连贯呈现。'], ['heart', '原生社区互动', '回复、点赞、Boost、回应、收藏、通知和帖子编辑无需离开阅读器。'], ['rocket', '长帖数据与性能', '按需加载、缓存和请求节奏控制,并集中管理历史、收藏与回应。'], ].map(([iconName, title, description]) => `<article class="ldp-about-feature"><span class="ldp-about-feature-icon">${icon(iconName)}</span> <div class="ldp-about-feature-copy"><strong>${title}</strong><p>${description}</p></div></article>`).join(''); } function popoverSearchMarkup(type, placeholder, label, clearLabel) { return `<label class="ldp-popover-search"> ${icon('search')} <input class="ldp-popover-search-input ldp-${type}-search" type="search" autocomplete="off" spellcheck="false" placeholder="${placeholder}" aria-label="${label}"> <button class="ldp-popover-search-clear ldp-${type}-search-clear" type="button" aria-label="${clearLabel}" hidden>${icon('x')}</button> </label>`; } function popoverPagerMarkup(type) { const prefix = `ldp-${type}-page`; const shared = type === 'notification' ? '' : ' ldp-notification-page'; return `<div class="ldp-notification-pager"> <button class="${prefix}-prev${shared ? `${shared}-prev` : ''}" type="button" aria-label="上一页" disabled>${icon('chevronRight', 'ldp-notification-prev-icon')}</button> <span class="${prefix}-info${shared ? `${shared}-info` : ''}">第 1 页</span> <button class="${prefix}-next${shared ? `${shared}-next` : ''}" type="button" aria-label="下一页" disabled>${icon('chevronRight')}</button> </div>`; } function collectionBulkActionsMarkup(type, scopeLabel, itemLabel, deleteLabel) { return `<div class="ldp-collection-title-actions ldp-${type}-bulk-actions" hidden> <select class="ldp-reader-select ldp-collection-scope ldp-${type}-select-scope" aria-label="${scopeLabel}全选范围"> <option value="page">本页</option> <option value="all">全部页</option> </select> <button class="ldp-collection-action ldp-${type}-select-toggle" type="button" aria-label="全选本页${itemLabel}" aria-pressed="false">${icon('square')}</button> <button class="ldp-collection-action danger ldp-${type}-delete-selected" type="button" aria-label="${deleteLabel}" disabled>${icon('trash')}<b class="ldp-collection-count ldp-${type}-delete-selected-label" hidden>0</b></button> <button class="ldp-collection-action ldp-${type}-multi-done" type="button" aria-label="退出多选">${icon('x')}</button> </div>`; } const RESOURCE_MONITOR_ROWS = [ ['heap', '页面内存估计', 'UA 专用内存 API;不可按脚本拆分'], ['longTasks', '10 秒共享长任务', '浏览器主线程;次数 / 累计耗时'], ['dom', '阅读器 DOM', '外壳内全部节点'], ['floors', '主楼层 DOM', '已挂载 / 内存保留'], ['nested', '楼中楼缓存', '已建立状态的父楼层组'], ['media', '正文媒体 DOM', '图片、音视频与 iframe'], ['requests', '当前阅读器请求', '执行中 / 排队'], ['network', '60 秒阅读器网络', '请求数 / 已知传输量'], ]; const RESOURCE_MONITOR_STAT_KEYS = Object.freeze([ 'heapUsed', 'longTaskCount', 'longTaskDuration', 'dom', 'mountedFloors', 'retainedFloors', 'nested', 'media', 'activeRequests', 'queuedRequests', 'readerRequestCount', 'readerTransfer', ]); function resourceMonitorRowMarkup([key, label, detail]) { return `<div class="ldp-resource-monitor-row" role="row" data-resource-monitor-row="${key}"> <span class="ldp-resource-monitor-copy"><strong>${label}</strong><small>${detail}</small></span> <strong class="ldp-resource-monitor-current" data-resource-monitor-metric="${key}">—</strong> <span class="ldp-resource-monitor-summary"><span data-resource-monitor-average="${key}">—</span><span data-resource-monitor-peak="${key}">—</span><span data-resource-monitor-trend="${key}">—</span></span> </div>`; } const SETTING_SWITCH_FIELDS = [ { section: 'image-lightbox', className: 'ldp-lightbox-comments-expanded', label: '默认展开图片评论', description: '开启后,打开图片灯箱时直接显示右侧评论面板;关闭时仍可通过顶部评论按钮展开。' }, { section: 'image-lightbox', className: 'ldp-lightbox-description-expanded', label: '默认展开图片描述', description: '描述可在灯箱内收纳,也可拖动底边调整高度;收纳状态与高度会自动记住。' }, { section: 'image-lightbox', className: 'ldp-lightbox-original-by-default', label: '灯箱浏览默认使用原图', description: '开启后,打开灯箱和前后翻图都会自动加载原图;关闭时先显示预览图,仍可手动查看原图。' }, { section: 'font-rendering', className: 'ldp-font-rendering-enabled', label: '内置字体渲染', detailHtml: '<small class="ldp-font-rendering-status" role="status">正在检测字体渲染环境…</small>' }, { section: 'font-rendering', className: 'ldp-font-rendering-on-host', label: '扩展到 LINUX DO 宿主页面', ariaLabel: '扩展字体渲染到 LINUX DO 宿主页面', description: '默认开启;主题列表、帖子原页和其它宿主界面也使用相同渲染。' }, { section: 'history', className: 'ldp-history-buttons-always-visible', label: '长显左右按钮', description: '始终显示浏览历史的左右导航按钮;开启后不再使用边缘触发区域。' }, { section: 'topic-opening', className: 'ldp-open-topics-first-post', label: '打开帖子起始楼层号为 #1', description: '普通帖子链接默认从 #1 打开;消息、历史和收藏仍按各自目标楼层打开。' }, { section: 'replies', className: 'ldp-expand-nested-replies-default', label: '默认展开二级回复', description: '开启后,父楼层下默认铺开直属二级回复;关闭时必须保留下面的对应楼层展开。' }, { section: 'replies', className: 'ldp-expand-leaf-nested-replies', label: '展开二级回复对应楼层', description: '开启后,二级回复在主信息流中的正式楼层默认完整展开;关闭时必须保留上面的直属回复展开。' }, ]; function settingSwitchMarkup(field) { return `<label class="ldp-setting-row ldp-setting-option-row"> <span class="ldp-setting-option-copy"> <strong>${field.label}</strong> ${field.detailHtml || `<small>${field.description}</small>`} </span> <span class="ldp-setting-switch"> <input class="${field.className}" type="checkbox" role="switch" aria-label="${field.ariaLabel || field.label}"> <span class="ldp-setting-switch-track" aria-hidden="true"></span> </span> </label>`; } function settingSwitchesMarkup(section) { return SETTING_SWITCH_FIELDS.filter((field) => field.section === section) .map(settingSwitchMarkup).join(''); } const BOOST_COPY_SETTING_ROWS = [ { label: '小尾巴类型', control: '<select class="ldp-reader-select ldp-boost-copy-mode ldp-boost-rule-control" aria-label="Boost 小尾巴类型"><option value="counter">数字递增</option><option value="text">固定文字</option></select>' }, { label: '开头文字', control: '<input class="ldp-boost-copy-prefix ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="可选,例如:赞同:" aria-label="Boost 开头文字">' }, { label: '数字前内容', className: ' ldp-boost-counter-row', control: '<input class="ldp-boost-copy-counter-marker ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="默认 +,也可填文字" aria-label="Boost 数字前内容">' }, { label: '递增步长', className: ' ldp-boost-counter-row', control: '<input class="ldp-boost-copy-counter-step ldp-boost-rule-control" type="number" min="1" max="99" step="1" inputmode="numeric" aria-label="Boost 递增步长">' }, { label: '固定尾巴', className: ' ldp-boost-text-row', hidden: true, control: '<input class="ldp-boost-copy-fixed-suffix ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="例如:俺也一样" aria-label="Boost 固定尾巴">' }, ]; function boostCopySettingsMarkup() { return BOOST_COPY_SETTING_ROWS.map((row) => `<label class="ldp-setting-row ldp-boost-rule-row${row.className || ''}"${row.hidden ? ' hidden' : ''}> <span class="ldp-setting-label">${row.label}</span> ${row.control} </label>`).join(''); } function fontFieldRowsMarkup(scope, config) { const host = scope === 'host'; const rowClass = `ldp-setting-row${host ? '' : ' ldp-setting-row-has-sharing'}`; const reset = (field) => `<button class="ldp-color-reset ${host ? 'ldp-host-font-reset' : 'ldp-font-field-reset'}" type="button" ${host ? 'data-host-font-reset' : 'data-font-field-reset'}="${host ? field : config[field]}">${icon('rotateCcw')}<span>默认</span></button>`; const sharing = (field, label) => host ? '' : profileFieldSharingControl('font', config[field], `${config.label}${label}`); const defaultFamily = host ? HOST_FONT_DEFAULTS.hostFontFamily : FONT_FAMILY_DEFAULT; const row = (label, controlClass, control, field) => `<div class="${rowClass}"> <span class="ldp-setting-label">${label}</span> <span class="${controlClass}">${control}${reset(field)}</span> ${sharing(field, label)} </div>`; return [ row('字体', 'ldp-font-option-control', ` <span class="ldp-font-family-picker" data-font-scope="${scope}"> <button class="ldp-font-family-trigger ldp-picker-trigger" type="button" data-font-scope="${scope}" aria-haspopup="listbox" aria-expanded="false" aria-controls="ldp-font-family-menu"> <span class="ldp-font-family-value">${FONT_FAMILY_OPTIONS[defaultFamily].label}</span>${icon('chevronDown')} </button> </span> <input class="ldp-font-family-custom" type="text" data-font-scope="${scope}" maxlength="64" autocomplete="off" spellcheck="false" placeholder="输入字体名称" aria-label="${config.label}自定义字体名称" hidden>`, 'family'), row('字重', 'ldp-font-option-control', ` <select class="ldp-reader-select ldp-font-weight-select" data-font-scope="${scope}" aria-label="${config.label}字重"> ${FONT_WEIGHT_OPTIONS.map((value) => `<option value="${value}">${value} · ${FONT_WEIGHT_LABELS[value]}</option>`).join('')} </select>`, 'weight'), row('颜色', 'ldp-color-control ldp-font-color-control', ` <input class="ldp-font-color" type="color" data-font-scope="${scope}" value="${READER_THEME_VARIABLES.light['--primary']}" aria-label="${config.label}${host ? '颜色' : '文字颜色'}"> <span class="ldp-accent-color-value ldp-font-color-value" data-font-scope="${scope}">跟随主题</span>`, 'color'), ].join(''); } function fontScopeSettingsMarkup(scope) { const config = FONT_SCOPE_CONFIG[scope]; const scaleClass = scope === 'interface' ? 'ldp-interface-font-scale' : scope === 'post' ? 'ldp-font-scale' : 'ldp-composer-font-scale'; return `<div class="ldp-setting-group ldp-font-scope-group" id="ldp-font-scope-panel-${scope}" role="tabpanel" data-font-scope-panel="${scope}" aria-labelledby="ldp-font-scope-tab-${scope}"${scope === 'interface' ? '' : ' hidden'}> ${fontFieldRowsMarkup(scope, config)} <div class="ldp-setting-row ldp-setting-row-has-sharing"> <span class="ldp-setting-label">字号</span> <span class="ldp-font-scale-control"> <input class="${scaleClass}-range" type="range" data-font-scope="${scope}" min="${FONT_SCALE_MIN}" max="${FONT_SCALE_MAX}" step="1" aria-label="${config.label}字号"> <span class="${scaleClass}-value"></span> <button class="ldp-color-reset ldp-font-field-reset" type="button" data-font-field-reset="${config.scale}">${icon('rotateCcw')}<span>默认</span></button> </span> ${profileFieldSharingControl('font', config.scale, `${config.label}字号`)} </div> </div>`; } function normalizePerformanceValue(name, value, fallback) { const limits = PERFORMANCE_LIMITS[name]; const numeric = Number(value); if (!limits || !Number.isFinite(numeric)) return fallback; const clamped = Math.min(limits.max, Math.max(limits.min, numeric)); return limits.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100; } function performanceConfigFromPrefs(prefs) { const source = prefs || {}; const fallback = BALANCED_PERFORMANCE; return Object.keys(PERFORMANCE_PREF_KEYS).reduce((config, name) => { config[name] = normalizePerformanceValue( name, source[PERFORMANCE_PREF_KEYS[name]], fallback[name] ); return config; }, {}); } function performancePresetForConfig(config) { const names = Object.keys(PERFORMANCE_PREF_KEYS); const match = Object.entries(PERFORMANCE_PRESETS).find(([, preset]) => names.every((name) => config[name] === preset[name]) ); return match ? match[0] : 'custom'; } function performancePrefsPatch(config, presetName = performancePresetForConfig(config)) { return Object.keys(PERFORMANCE_PREF_KEYS).reduce((patch, name) => { patch[PERFORMANCE_PREF_KEYS[name]] = config[name]; return patch; }, { performancePreset: presetName }); } function normalizePerformancePrefs(prefs) { const config = performanceConfigFromPrefs(prefs); return Object.assign({}, prefs, performancePrefsPatch(config)); } function roundLayoutRatio(value) { return Math.round(Number(value) * 100) / 100; } function layoutRatioTotal(ratios) { return roundLayoutRatio(LAYOUT_REGION_KEYS.reduce((sum, name) => sum + Number(ratios[name] || 0), 0)); } function normalizeLayoutRatios(source, fallback = LAYOUT_PROFILE_DEFAULTS.full) { const values = source || {}; const result = LAYOUT_REGION_KEYS.reduce((ratios, name) => { const numeric = Number(values[name]); const safe = Number.isFinite(numeric) ? numeric : fallback[name]; ratios[name] = roundLayoutRatio(Math.min(100, Math.max(LAYOUT_MIN_RATIOS[name], safe))); return ratios; }, {}); const total = layoutRatioTotal(result); if (total <= 100) return result; const minimumTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + LAYOUT_MIN_RATIOS[name], 0); const extraBudget = 100 - minimumTotal; const extras = LAYOUT_REGION_KEYS.reduce((map, name) => { map[name] = Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]); return map; }, {}); const extraTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + extras[name], 0) || 1; LAYOUT_REGION_KEYS.forEach((name) => { result[name] = roundLayoutRatio(LAYOUT_MIN_RATIOS[name] + extraBudget * extras[name] / extraTotal); }); let overflow = roundLayoutRatio(layoutRatioTotal(result) - 100); if (overflow > 0) { const correctionKey = LAYOUT_REGION_KEYS.find((name) => result[name] - overflow >= LAYOUT_MIN_RATIOS[name]); if (correctionKey) result[correctionKey] = roundLayoutRatio(result[correctionKey] - overflow); overflow = roundLayoutRatio(layoutRatioTotal(result) - 100); if (overflow > 0) result.main = roundLayoutRatio(result.main - overflow); } return result; } function mapProfileValues(keys, resolveValue) { return keys.reduce((profiles, profile) => { profiles[profile] = resolveValue(profile); return profiles; }, {}); } function profileCollectionNormalizer(keys, defaults, normalizeProfile) { return (source) => { const profiles = source || {}; return mapProfileValues(keys, (profile) => normalizeProfile(profiles[profile], defaults[profile])); }; } const normalizeLayoutProfiles = profileCollectionNormalizer(READER_PROFILE_KEYS, LAYOUT_PROFILE_DEFAULTS, normalizeLayoutRatios); const normalizeImageProfiles = profileCollectionNormalizer(READER_PROFILE_KEYS, IMAGE_PROFILE_DEFAULTS, normalizeImageProfile); const normalizeFontProfiles = profileCollectionNormalizer(READER_PROFILE_KEYS, FONT_PROFILE_DEFAULTS, normalizeFontProfile); const normalizeAppearanceProfiles = profileCollectionNormalizer(READER_PROFILE_KEYS, APPEARANCE_PROFILE_DEFAULTS, normalizeAppearanceProfile); function legacyLayoutRatios(source) { const stored = source || {}; const hasLegacy = LAYOUT_REGION_KEYS.some((name) => Object.prototype.hasOwnProperty.call(stored, LEGACY_LAYOUT_PREF_KEYS[name]) ); if (!hasLegacy) return null; const ratios = LAYOUT_REGION_KEYS.reduce((result, name) => { result[name] = stored[LEGACY_LAYOUT_PREF_KEYS[name]]; return result; }, {}); return normalizeLayoutRatios(ratios, LAYOUT_PROFILE_DEFAULTS.full); } function normalizeListReaderMode(value) { return LIST_READER_MODES.includes(value) ? value : 'floating'; } function normalizeTopicReaderMode(value) { return TOPIC_READER_MODES.includes(value) ? value : 'floating'; } function normalizeBoostCopyString(value, fallback = '') { const normalized = String(value == null ? fallback : value).replace(/\s+/g, ' '); return Array.from(normalized).slice(0, BOOST_COPY_MAX_LENGTH).join(''); } function boostCopyConfigFromPrefs(prefs) { const source = prefs || {}; const mode = BOOST_COPY_MODES.includes(source.boostCopyMode) ? source.boostCopyMode : BOOST_COPY_DEFAULTS.mode; const counterMarker = normalizeBoostCopyString( source.boostCopyCounterMarker, BOOST_COPY_DEFAULTS.counterMarker ).trim() || BOOST_COPY_DEFAULTS.counterMarker; const counterStep = Number(source.boostCopyCounterStep); return { mode, prefix: normalizeBoostCopyString(source.boostCopyPrefix, BOOST_COPY_DEFAULTS.prefix), counterMarker, counterStep: Number.isFinite(counterStep) ? Math.min(99, Math.max(1, Math.round(counterStep))) : BOOST_COPY_DEFAULTS.counterStep, fixedSuffix: normalizeBoostCopyString(source.boostCopyFixedSuffix, BOOST_COPY_DEFAULTS.fixedSuffix), }; } function normalizeJumpHighlightValue(name, value) { const limits = JUMP_HIGHLIGHT_LIMITS[name]; const fallback = JUMP_HIGHLIGHT_DEFAULTS[name]; const numeric = Number(value); if (!limits || !Number.isFinite(numeric)) return fallback; const clamped = Math.min(limits.max, Math.max(limits.min, numeric)); if (limits.integer) return Math.round(clamped); const stepped = Math.round(clamped / limits.step) * limits.step; return Math.round(stepped * 100) / 100; } function jumpHighlightConfigFromPrefs(prefs) { const source = prefs || {}; const color = String(source.jumpHighlightColor || '').trim().toLowerCase(); return { color: /^#[0-9a-f]{6}$/.test(color) ? color : JUMP_HIGHLIGHT_DEFAULTS.color, radius: normalizeJumpHighlightValue('radius', source.jumpHighlightRadius), borderWidth: normalizeJumpHighlightValue('borderWidth', source.jumpHighlightBorderWidth), rate: normalizeJumpHighlightValue('rate', source.jumpHighlightRate), count: normalizeJumpHighlightValue('count', source.jumpHighlightCount), }; } function jumpHighlightPrefsPatch(config) { return Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => [field.pref, config[field.key]])); } function jumpHighlightDurationMs(config = jumpHighlightConfigFromPrefs(PREFS)) { return Math.round(1000 / config.rate); } function normalizeListReaderEmbedWidth(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return READER_EMBED_DEFAULT_WIDTH; return Math.max(READER_EMBED_MIN_WIDTH, Math.round(numeric)); } function normalizeReaderWindowPreferenceGroup(prefs, prefix) { const width = Number(prefs[`${prefix}Width`]); const height = Number(prefs[`${prefix}Height`]); const x = Number(prefs[`${prefix}X`]); const y = Number(prefs[`${prefix}Y`]); return { [`${prefix}Width`]: Number.isFinite(width) && width > 0 ? Math.max(READER_WINDOW_MIN_WIDTH, Math.round(width)) : 0, [`${prefix}Height`]: Number.isFinite(height) && height > 0 ? Math.max(READER_WINDOW_MIN_HEIGHT, Math.round(height)) : 0, [`${prefix}X`]: Number.isFinite(x) && x > 0 ? Math.round(x) : 0, [`${prefix}Y`]: Number.isFinite(y) && y > 0 ? Math.round(y) : 0, [`${prefix}Locked`]: !!prefs[`${prefix}Locked`], [`${prefix}Pinned`]: !!prefs[`${prefix}Pinned`], }; } function normalizeWindowGeometryPreferenceGroup(prefs, prefix) { const normalized = ['Width', 'Height', 'X', 'Y'].reduce((result, suffix) => { const value = Number(prefs[`${prefix}${suffix}`]); result[`${prefix}${suffix}`] = Number.isFinite(value) && value > 0 ? Math.round(value) : 0; return result; }, {}); return normalized; } function normalizeReaderThemeMode(value) { return READER_THEME_MODES.includes(value) ? value : 'system'; } function normalizeReaderLoadingAnimation(value) { return value === 'random' || READER_LOADING_ANIMATION_KEYS.includes(value) ? value : 'random'; } function normalizeBookmarkTabOrder(value) { const stored = Array.isArray(value) ? [...new Set(value.filter((type) => BOOKMARK_TAB_TYPES.includes(type)))] : []; return [...stored, ...BOOKMARK_TAB_TYPES.filter((type) => !stored.includes(type))]; } function normalizeReaderProfileSharing(source) { const sharing = source || {}; return READER_PROFILE_SHARING_KEYS.reduce((result, category) => { const categorySharing = sharing[category]; result[category] = READER_PROFILE_SHARING_FIELDS[category].reduce((fields, field) => { const storedFields = categorySharing && typeof categorySharing === 'object' ? categorySharing : null; const legacyFontFamilyShare = category === 'font' && (field === 'postFamily' || field === 'composerFamily') && storedFields && !Object.prototype.hasOwnProperty.call(storedFields, field) && storedFields.family === true; const legacyFontWeightShare = category === 'font' && (field === 'postWeight' || field === 'composerWeight') && storedFields && !Object.prototype.hasOwnProperty.call(storedFields, field) && storedFields.weight === true; fields[field] = categorySharing === true || !!(storedFields && storedFields[field] === true) || legacyFontFamilyShare || legacyFontWeightShare; return fields; }, {}); return result; }, {}); } function normalizeReaderPrefs(prefs) { const performancePrefs = normalizePerformancePrefs(prefs); const boostCopy = boostCopyConfigFromPrefs(performancePrefs); const jumpHighlight = jumpHighlightConfigFromPrefs(performancePrefs); const expandLeafNestedReplies = performancePrefs.expandLeafNestedReplies === true; const expandNestedRepliesByDefault = performancePrefs.expandNestedRepliesByDefault !== false || !expandLeafNestedReplies; return Object.assign({}, performancePrefs, { imageProfiles: normalizeImageProfiles(performancePrefs.imageProfiles), sharedImageProfile: normalizeImageProfile(performancePrefs.sharedImageProfile, IMAGE_PROFILE_DEFAULTS.full), lightboxOriginalByDefault: performancePrefs.lightboxOriginalByDefault === true, lightboxCommentsExpandedByDefault: performancePrefs.lightboxCommentsExpandedByDefault === true, lightboxDescriptionExpanded: performancePrefs.lightboxDescriptionExpanded === true, lightboxDescriptionHeight: normalizeLightboxDescriptionHeight(performancePrefs.lightboxDescriptionHeight), lightboxCommentsWidthPercent: normalizeLightboxCommentsWidth(performancePrefs.lightboxCommentsWidthPercent), fontProfiles: normalizeFontProfiles(performancePrefs.fontProfiles), sharedFontProfile: normalizeFontProfile(performancePrefs.sharedFontProfile, FONT_PROFILE_DEFAULTS.full), layoutProfiles: normalizeLayoutProfiles(performancePrefs.layoutProfiles), sharedLayoutProfile: normalizeLayoutRatios(performancePrefs.sharedLayoutProfile, LAYOUT_PROFILE_DEFAULTS.full), appearanceProfiles: normalizeAppearanceProfiles(performancePrefs.appearanceProfiles), sharedAppearanceProfile: normalizeAppearanceProfile(performancePrefs.sharedAppearanceProfile, APPEARANCE_PROFILE_DEFAULTS.full), profileSharing: normalizeReaderProfileSharing(performancePrefs.profileSharing), themeMode: normalizeReaderThemeMode(performancePrefs.themeMode), fontRenderingEnabled: performancePrefs.fontRenderingEnabled !== false, fontRenderingOnHost: performancePrefs.fontRenderingOnHost === true, hostFontFamily: normalizeFontFamily(performancePrefs.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily), hostFontCustomFamily: normalizeCustomFontFamily(performancePrefs.hostFontCustomFamily, HOST_FONT_DEFAULTS.hostFontCustomFamily), hostFontWeight: normalizeFontWeight(performancePrefs.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight), hostFontColor: normalizeHexColor(performancePrefs.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor), ...Object.fromEntries(Object.values(HOST_EMBED_SIZE_SETTINGS).map((config) => [ config.pref, normalizeHostEmbeddedSizeScale(performancePrefs[config.pref], config.defaultScale), ])), ...normalizeReaderWindowPreferenceGroup(performancePrefs, 'readerWindow'), ...normalizeReaderWindowPreferenceGroup(performancePrefs, 'listReaderWindow'), ...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PROFILE_PREFIXES.popup), ...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PROFILE_PREFIXES.full), ...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PROFILE_PREFIXES.mobile), topicReaderMode: normalizeTopicReaderMode(performancePrefs.topicReaderMode), listReaderMode: normalizeListReaderMode(performancePrefs.listReaderMode), listReaderEmbedWidth: normalizeListReaderEmbedWidth(performancePrefs.listReaderEmbedWidth), historySortMode: performancePrefs.historySortMode === 'first-viewed' ? 'first-viewed' : 'recent-viewed', bookmarkTabOrder: normalizeBookmarkTabOrder(performancePrefs.bookmarkTabOrder), historyButtonsAlwaysVisible: performancePrefs.historyButtonsAlwaysVisible === true, historyEdgeTriggerPercent: normalizeHistoryEdgeTriggerPercent(performancePrefs.historyEdgeTriggerPercent), loadingAnimation: normalizeReaderLoadingAnimation(performancePrefs.loadingAnimation), openTopicsAtFirstPost: performancePrefs.openTopicsAtFirstPost === true, expandNestedRepliesByDefault, expandLeafNestedReplies, ...jumpHighlightPrefsPatch(jumpHighlight), boostCopyMode: boostCopy.mode, boostCopyPrefix: boostCopy.prefix, boostCopyCounterMarker: boostCopy.counterMarker, boostCopyCounterStep: boostCopy.counterStep, boostCopyFixedSuffix: boostCopy.fixedSuffix, }); } function layoutRatioMax(ratios, name) { return roundLayoutRatio(100 - LAYOUT_REGION_KEYS.reduce((sum, other) => { return sum + (other === name ? 0 : ratios[other]); }, 0)); } function readPrefs() { try { const stored = JSON.parse(localStorage.getItem(LDP_PREF_KEY) || '{}'); const merged = Object.assign({}, DEFAULT_PREFS, stored); const migrateLegacyZebraDefaults = (source) => { if (!source || typeof source !== 'object') return source; const migrated = Object.assign({}, source); [ ['zebraColor', LEGACY_ZEBRA_COLOR_DEFAULT, ZEBRA_COLOR_DEFAULT, true], ['listZebraColor', LEGACY_ZEBRA_COLOR_DEFAULT, LIST_ZEBRA_COLOR_DEFAULT, true], ['listZebraColor', LEGACY_LIST_ZEBRA_COLOR_DEFAULT, LIST_ZEBRA_COLOR_DEFAULT, true], ['quoteLineWidth', LEGACY_STRUCTURE_LINE_WIDTH_DEFAULT, QUOTE_LINE_WIDTH_DEFAULT], ['dividerLineWidth', LEGACY_STRUCTURE_LINE_WIDTH_DEFAULT, DIVIDER_LINE_WIDTH_DEFAULT], ['replyLineWidth', LEGACY_REPLY_LINE_WIDTH_DEFAULT, REPLY_LINE_WIDTH_DEFAULT], ['floorPreviewColor', LEGACY_FLOOR_PREVIEW_COLOR_DEFAULT, FLOOR_PREVIEW_COLOR_DEFAULT, true], ['floorPreviewColor', LEGACY_FLOOR_PREVIEW_COLOR_DEFAULT_V2, FLOOR_PREVIEW_COLOR_DEFAULT, true], ].forEach(([key, legacyValue, replacement, color]) => { const value = color ? String(migrated[key] || '').toLowerCase() : Number(migrated[key]); if (value === legacyValue) migrated[key] = replacement; }); return migrated; }; if (stored.appearanceProfiles) { merged.appearanceProfiles = mapProfileValues(READER_PROFILE_KEYS, (profile) => migrateLegacyZebraDefaults(stored.appearanceProfiles[profile])); } if (stored.sharedAppearanceProfile) { merged.sharedAppearanceProfile = migrateLegacyZebraDefaults(stored.sharedAppearanceProfile); } if (!Object.prototype.hasOwnProperty.call(stored, 'expandNestedRepliesByDefault')) { merged.expandNestedRepliesByDefault = stored.expandLeafNestedReplies !== true; } if (!stored.imageProfiles) { const legacyImage = normalizeImageProfile({ preset: stored.imageScalePreset, custom: stored.imageScaleCustom, }); merged.imageProfiles = mapProfileValues(READER_PROFILE_KEYS, () => Object.assign({}, legacyImage)); } if (!stored.appearanceProfiles) { const legacyZebraColor = normalizeZebraColor(stored.zebraColor); const legacyAppearance = normalizeAppearanceProfile({ zebraColor: legacyZebraColor, listZebraColor: Object.prototype.hasOwnProperty.call(stored, 'listZebraColor') ? stored.listZebraColor : legacyZebraColor, replyLineColor: stored.replyLineColor, quoteLineColor: stored.quoteLineColor, dividerLineColor: stored.dividerLineColor, }); merged.appearanceProfiles = mapProfileValues(READER_PROFILE_KEYS, () => Object.assign({}, legacyAppearance)); } if (!stored.layoutProfiles) { const migratedFull = legacyLayoutRatios(stored); merged.layoutProfiles = Object.assign({}, LAYOUT_PROFILE_DEFAULTS, migratedFull ? { full: migratedFull } : {}); } if (!stored.fontProfiles) { const legacyProfile = { post: normalizeFontScale(stored.postFontScale, FONT_SCALE_DEFAULT), composer: normalizeFontScale(stored.composerFontScale, COMPOSER_FONT_SCALE_DEFAULT), }; merged.fontProfiles = mapProfileValues(READER_PROFILE_KEYS, (profile) => Object.assign({ interface: FONT_PROFILE_DEFAULTS[profile].interface, }, legacyProfile)); } const storedPreset = Object.prototype.hasOwnProperty.call(stored, 'performancePreset') ? stored.performancePreset : ''; if (PERFORMANCE_PRESETS[storedPreset]) { Object.assign(merged, performancePrefsPatch(PERFORMANCE_PRESETS[storedPreset], storedPreset)); } return normalizeReaderPrefs(merged); } catch (e) { return normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS)); } } let PREFS = readPrefs(); function externalFontRenderingDetected() { const root = document.documentElement; if (!root.hasAttribute(EXTERNAL_FONT_RENDERING_MARKER)) return false; return getComputedStyle(root).getPropertyValue('--fr-render-text').trim() !== ''; } function syncReaderFontRenderingState() { applyHostEmbeddedSizeSettings(); applyHostFontAppearance(); const external = externalFontRenderingDetected(); const mode = external ? 'external' : PREFS.fontRenderingEnabled ? 'builtin' : 'off'; document.documentElement.dataset.ldpFontRendering = mode; document.documentElement.dataset.ldpFontRenderingHost = String( mode === 'builtin' && PREFS.fontRenderingOnHost === true ); if (READER_PORTAL_HOST) READER_PORTAL_HOST.dataset.ldpFontRendering = mode; readerPortalQueryAll('.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox') .forEach((surface) => { surface.dataset.ldpFontRendering = mode; }); readerPortalQueryAll('.ldp-font-rendering-enabled').forEach((input) => { input.checked = PREFS.fontRenderingEnabled !== false; input.disabled = external; if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管'; else delete input.dataset.ldpTooltipLabel; }); readerPortalQueryAll('.ldp-font-rendering-on-host').forEach((input) => { input.checked = PREFS.fontRenderingOnHost === true; input.disabled = external || PREFS.fontRenderingEnabled === false; if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管'; else delete input.dataset.ldpTooltipLabel; }); readerPortalQueryAll('.ldp-font-rendering-status').forEach((status) => { status.dataset.mode = mode; status.textContent = external ? '检测到 F9y4ng 字体渲染,内置效果已自动让位,避免双重描边与阴影。' : mode === 'builtin' ? PREFS.fontRenderingOnHost ? '内置渲染已作用于增强阅读器和 LINUX DO 宿主页面。' : '内置渲染已作用于增强阅读器;宿主页面保持原样。' : '内置字体渲染已关闭。'; }); } new MutationObserver((mutations) => { if (mutations.some((mutation) => mutation.attributeName === EXTERNAL_FONT_RENDERING_MARKER)) { syncReaderFontRenderingState(); [50, 250, 1000].forEach((delay) => { setTimeout(syncReaderFontRenderingState, delay); }); } }).observe(document.documentElement, { attributes: true, attributeFilter: [EXTERNAL_FONT_RENDERING_MARKER], }); window.addEventListener('load', syncReaderFontRenderingState, { once: true }); syncReaderFontRenderingState(); function setPrefs(patch) { PREFS = normalizeReaderPrefs(Object.assign({}, PREFS, patch)); try { localStorage.setItem(LDP_PREF_KEY, JSON.stringify(PREFS)); } catch (e) {} if (Object.keys(patch).some((key) => /^(?:fontRendering|hostFont|hostEmbedded)/.test(key))) syncReaderFontRenderingState(); } function replacePrefs(nextPrefs) { const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, nextPrefs || {})); try { localStorage.setItem(LDP_PREF_KEY, JSON.stringify(normalized)); PREFS = normalized; syncReaderFontRenderingState(); return true; } catch (e) { return false; } } function replacePrefsAndReload(nextPrefs, failureMessage) { if (!replacePrefs(nextPrefs)) { showSelectionToast(failureMessage); return; } location.reload(); } function readerConfigSettingKeys() { return Object.keys(normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS))); } function readerConfigSettings(prefs = PREFS) { const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, prefs)); return readerConfigSettingKeys().reduce((settings, key) => { settings[key] = normalized[key]; return settings; }, {}); } function readerConfigExportPayload() { const settings = readerConfigSettings(); return { format: LDP_CONFIG_EXPORT_FORMAT, schemaVersion: LDP_CONFIG_EXPORT_VERSION, scriptVersion: READER_VERSION, exportedAt: new Date().toISOString(), settingsCount: Object.keys(settings).length, settings, }; } function readerPrefsFromConfigExport(payload) { const schemaVersion = Number(payload && payload.schemaVersion); const settingKeys = readerConfigSettingKeys(); const optionalLegacySettingKeys = new Set(['full', 'mobile'].flatMap((profile) => { const prefix = COMPOSER_WINDOW_PROFILE_PREFIXES[profile]; return ['Width', 'Height', 'X', 'Y'].map((suffix) => `${prefix}${suffix}`); })); const payloadSettingKeys = payload && payload.settings && typeof payload.settings === 'object' && !Array.isArray(payload.settings) ? Object.keys(payload.settings) : []; if (!payload || payload.format !== LDP_CONFIG_EXPORT_FORMAT || schemaVersion !== LDP_CONFIG_EXPORT_VERSION || !payload.settings || typeof payload.settings !== 'object' || Array.isArray(payload.settings) || Number(payload.settingsCount) !== payloadSettingKeys.length || payloadSettingKeys.some((key) => !settingKeys.includes(key)) || settingKeys.some((key) => !optionalLegacySettingKeys.has(key) && !Object.prototype.hasOwnProperty.call(payload.settings, key))) { throw new Error('invalid_config'); } const importedSettings = settingKeys.reduce((settings, key) => { if (Object.prototype.hasOwnProperty.call(payload.settings, key)) settings[key] = payload.settings[key]; return settings; }, {}); return normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, importedSettings)); } function downloadReaderConfigExport() { const blob = new window.Blob([`${JSON.stringify(readerConfigExportPayload(), null, 2)}\n`], { type: 'application/json' }); triggerBrowserDownload(blob, `awesome-linuxdo-reader-settings-${new Date().toISOString().slice(0, 10)}.json`); } function setPref(key, value) { setPrefs({ [key]: value }); } function currentReaderProfileSharing() { return normalizeReaderProfileSharing(PREFS.profileSharing); } function readerProfileSharingEnabled(category) { return READER_PROFILE_SHARING_KEYS.includes(category) && READER_PROFILE_SHARING_FIELDS[category].some((field) => readerProfileSettingShared(category, field)); } function readerProfileSettingShared(category, field) { return READER_PROFILE_SHARING_KEYS.includes(category) && READER_PROFILE_SHARING_FIELDS[category].includes(field) && currentReaderProfileSharing()[category][field]; } function readerProfileSharingCount(category) { return READER_PROFILE_SHARING_KEYS.includes(category) ? READER_PROFILE_SHARING_FIELDS[category].filter((field) => readerProfileSettingShared(category, field)).length : 0; } function mergeSharedProfileValues(profileValues, sharedValues, fields, isShared) { const values = Object.assign({}, profileValues); fields.forEach((field) => { if (isShared(field)) values[field] = sharedValues[field]; }); return values; } function splitSharedProfileValues(values, profileValues, sharedValues, fields, isShared) { const profile = Object.assign({}, profileValues); const shared = Object.assign({}, sharedValues); fields.forEach((field) => { (isShared(field) ? shared : profile)[field] = values[field]; }); return [profile, shared]; } const READER_SYSTEM_THEME_QUERY = typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null; let linuxDoThemeEventService = null; let linuxDoThemeChangeInProgress = false; let linuxDoThemeStylesheetObserver = null; let linuxDoThemeStylesheets = []; function linuxDoInterfaceColorService() { try { const registered = lookupDiscourse('service:interface-color'); if (registered) return registered; const appModule = tryRequire('discourse/app'); const container = appModule?.default?.__container__ || appModule?.__container__; return container?.lookup?.('service:interface-color') || null; } catch (error) { return null; } } function linuxDoNativeThemeMode(service = linuxDoInterfaceColorService()) { if (service && service.colorMode === 'light') return 'light'; if (service && service.colorMode === 'dark') return 'dark'; if (service && service.colorMode === 'auto') return 'system'; if (service?.lightModeForced || service?.forcedColorMode === 'light') return 'light'; if (service?.darkModeForced || service?.forcedColorMode === 'dark') return 'dark'; if (service?.selectorAvailable) return 'system'; const lightMedia = document.querySelector('link.light-scheme')?.media?.trim().toLowerCase() || ''; const darkMedia = document.querySelector('link.dark-scheme')?.media?.trim().toLowerCase() || ''; if (lightMedia.includes('prefers-color-scheme') && darkMedia.includes('prefers-color-scheme')) return 'system'; if (lightMedia === 'all' && darkMedia === 'none') return 'light'; if (lightMedia === 'none' && darkMedia === 'all') return 'dark'; return ''; } function readerUsesOwnThemePalette() { return !!CURRENT_OVERLAY?.classList.contains('ldp-reader-embedded'); } function currentReaderThemeMode() { return readerUsesOwnThemePalette() ? normalizeReaderThemeMode(PREFS.themeMode) : linuxDoNativeThemeMode() || normalizeReaderThemeMode(PREFS.themeMode); } function resolvedReaderThemeMode(mode = currentReaderThemeMode()) { const normalized = normalizeReaderThemeMode(mode); return normalized === 'system' && READER_SYSTEM_THEME_QUERY && READER_SYSTEM_THEME_QUERY.matches ? 'dark' : normalized === 'dark' ? 'dark' : 'light'; } function readerThemeIconName(mode = currentReaderThemeMode()) { return mode === 'light' ? 'sun' : mode === 'dark' ? 'moon' : 'monitor'; } function applyReaderThemeVariables(node, mode = currentReaderThemeMode(), ownPalette = readerUsesOwnThemePalette()) { if (!node || !node.style) return; const normalized = normalizeReaderThemeMode(mode); const resolved = resolvedReaderThemeMode(normalized); node.dataset.ldpThemeMode = normalized; node.dataset.ldpTheme = resolved; node.style.colorScheme = resolved; Object.entries(READER_THEME_VARIABLES[resolved]).forEach(([property, value]) => { if (ownPalette || property.startsWith('--ldp-')) node.style.setProperty(property, value); else node.style.removeProperty(property); }); applyAppearanceInteractionColors(node, currentAppearanceProfile(), normalized); } function clearLegacyLinuxDoThemeVariables() { const root = document.documentElement; if (!root || !root.style) return; if (!root.dataset.ldpThemeMode && !root.dataset.ldpTheme) return; root.style.removeProperty('color-scheme'); Object.keys(READER_THEME_VARIABLES.light).forEach((property) => { if (!property.startsWith('--ldp-')) root.style.removeProperty(property); }); delete root.dataset.ldpThemeMode; delete root.dataset.ldpTheme; } function bindLinuxDoNativeThemeEvents(service = linuxDoInterfaceColorService()) { if (service && linuxDoThemeEventService !== service && typeof service.appEvents?.on === 'function') { if (linuxDoThemeEventService && typeof linuxDoThemeEventService.appEvents?.off === 'function') { linuxDoThemeEventService.appEvents.off('interface-color:changed', document, onLinuxDoInterfaceColorChanged); } service.appEvents.on('interface-color:changed', document, onLinuxDoInterfaceColorChanged); linuxDoThemeEventService = service; } const stylesheets = [ document.querySelector('link.light-scheme'), document.querySelector('link.dark-scheme'), ].filter(Boolean); if (stylesheets.length === linuxDoThemeStylesheets.length && stylesheets.every((node, index) => node === linuxDoThemeStylesheets[index])) return; if (linuxDoThemeStylesheetObserver) linuxDoThemeStylesheetObserver.disconnect(); linuxDoThemeStylesheets = stylesheets; if (!stylesheets.length || typeof MutationObserver !== 'function') return; linuxDoThemeStylesheetObserver = new MutationObserver(onLinuxDoInterfaceColorChanged); stylesheets.forEach((node) => linuxDoThemeStylesheetObserver.observe(node, { attributes: true, attributeFilter: ['media'], })); } function applyLinuxDoNativeThemeFallback(mode) { const normalized = normalizeReaderThemeMode(mode); const lightStylesheet = document.querySelector('link.light-scheme'); const darkStylesheet = document.querySelector('link.dark-scheme'); if (!lightStylesheet || !darkStylesheet) return false; if (normalized === 'system') { lightStylesheet.media = '(prefers-color-scheme: light)'; darkStylesheet.media = '(prefers-color-scheme: dark)'; document.cookie = 'forced_color_mode=; path=/; max-age=0; SameSite=Lax'; } else { lightStylesheet.media = normalized === 'light' ? 'all' : 'none'; darkStylesheet.media = normalized === 'dark' ? 'all' : 'none'; document.cookie = `forced_color_mode=${normalized}; path=/; max-age=31536000; SameSite=Lax`; } return true; } function applyLinuxDoNativeTheme(mode = currentReaderThemeMode()) { clearLegacyLinuxDoThemeVariables(); const service = linuxDoInterfaceColorService(); bindLinuxDoNativeThemeEvents(service); const normalized = normalizeReaderThemeMode(mode); const actions = normalized === 'light' ? ['forceLightMode'] : normalized === 'dark' ? ['forceDarkMode'] : ['removeColorModeOverride', 'useAutoMode']; const action = actions.find((name) => typeof service?.[name] === 'function'); if (!action) return applyLinuxDoNativeThemeFallback(normalized); linuxDoThemeChangeInProgress = true; try { service[action](); return true; } catch (error) { return applyLinuxDoNativeThemeFallback(normalized); } finally { linuxDoThemeChangeInProgress = false; } } function syncReaderThemeButtons( buttons = readerPortalQueryAll('.ldp-settings-theme-button'), mode = currentReaderThemeMode() ) { const normalizedMode = normalizeReaderThemeMode(mode); buttons.forEach((button) => { const buttonMode = normalizeReaderThemeMode(button.dataset.readerThemeMode); const active = buttonMode === normalizedMode; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); button.setAttribute('aria-label', `主题:${READER_THEME_LABELS[buttonMode]}${active ? '(当前)' : ''}`); }); } function syncReaderThemeSurfaces(themeMode = currentReaderThemeMode()) { const mode = normalizeReaderThemeMode(themeMode); const ownPalette = readerUsesOwnThemePalette(); bindLinuxDoNativeThemeEvents(); if (READER_PORTAL_CONTEXT_BODY) { Array.from(READER_PORTAL_CONTEXT_BODY.children).forEach((node) => applyReaderThemeVariables(node, mode, ownPalette)); } if (CURRENT_OVERLAY && !CURRENT_OVERLAY.isConnected) applyReaderThemeVariables(CURRENT_OVERLAY, mode, ownPalette); document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => applyReaderThemeVariables(node, mode, ownPalette)); syncReaderThemeButtons(undefined, mode); if (CURRENT_OVERLAY && typeof CURRENT_OVERLAY._ldpApplyThemeDependentColors === 'function') { CURRENT_OVERLAY._ldpApplyThemeDependentColors(); } } function onLinuxDoInterfaceColorChanged() { if (linuxDoThemeChangeInProgress) return; const mode = linuxDoNativeThemeMode(); if (!mode) return; if (normalizeReaderThemeMode(PREFS.themeMode) !== mode) setPref('themeMode', mode); syncReaderThemeSurfaces(mode); } function transitionReaderTheme(mode, options = {}) { const nextMode = normalizeReaderThemeMode(mode); const persist = options.persist !== false; const applyHost = options.applyHost !== false; if (persist) setPref('themeMode', nextMode); if (applyHost) applyLinuxDoNativeTheme(nextMode); syncReaderThemeSurfaces(nextMode); } const onReaderSystemThemeChange = () => { if (currentReaderThemeMode() === 'system') { transitionReaderTheme('system', { persist: false, applyHost: false }); } }; if (READER_SYSTEM_THEME_QUERY) { if (typeof READER_SYSTEM_THEME_QUERY.addEventListener === 'function') { READER_SYSTEM_THEME_QUERY.addEventListener('change', onReaderSystemThemeChange); } else if (typeof READER_SYSTEM_THEME_QUERY.addListener === 'function') { READER_SYSTEM_THEME_QUERY.addListener(onReaderSystemThemeChange); } } clearLegacyLinuxDoThemeVariables(); function currentLayoutProfiles() { return normalizeLayoutProfiles(PREFS.layoutProfiles); } function currentSharedLayoutProfile() { return normalizeLayoutRatios(PREFS.sharedLayoutProfile, LAYOUT_PROFILE_DEFAULTS.full); } function balanceLayoutRatios(source, adjustable) { const result = Object.assign({}, source); const difference = roundLayoutRatio(100 - layoutRatioTotal(result)); const direction = difference > 0 ? 1 : -1; let remaining = Math.abs(difference); adjustable.forEach((name) => { if (remaining <= 0) return; const capacity = roundLayoutRatio(direction > 0 ? 100 - result[name] : Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name])); const change = Math.min(capacity, remaining); result[name] = roundLayoutRatio(result[name] + direction * change); remaining = roundLayoutRatio(remaining - change); }); return result; } function resolveSharedLayoutRatios(baseRatios, sharedRatios, sharing = currentReaderProfileSharing().layout, excludedName = '') { const base = normalizeLayoutRatios(baseRatios, LAYOUT_PROFILE_DEFAULTS.full); const shared = normalizeLayoutRatios(sharedRatios, LAYOUT_PROFILE_DEFAULTS.full); const result = Object.assign({}, base); const sharedFields = LAYOUT_REGION_KEYS.filter((name) => sharing[name]); if (!sharedFields.length) return result; sharedFields.forEach((name) => { result[name] = shared[name]; }); const adjustable = ['main', 'left', 'right', 'gap', 'timeline'] .filter((name) => !sharing[name] && name !== excludedName); return balanceLayoutRatios(result, adjustable); } function rebalanceLayoutRatiosAfterChange(source, editedName, sharing = currentReaderProfileSharing().layout) { const preferred = ['main', 'left', 'right', 'gap', 'timeline'].filter((name) => name !== editedName); const unshared = preferred.filter((name) => sharing[name] !== true); const adjustable = unshared.length ? unshared : preferred; return balanceLayoutRatios(source, adjustable); } function currentLayoutRatios(profile = 'full') { const safeProfile = READER_PROFILE_KEYS.includes(profile) ? profile : 'full'; const values = currentLayoutProfiles()[safeProfile]; return readerProfileSharingEnabled('layout') ? resolveSharedLayoutRatios(values, currentSharedLayoutProfile()) : values; } function currentFontProfiles() { return normalizeFontProfiles(PREFS.fontProfiles); } function currentSharedFontProfile() { return normalizeFontProfile(PREFS.sharedFontProfile, FONT_PROFILE_DEFAULTS.full); } function fontProfileSharingField(name) { const scope = FONT_SCOPE_KEYS.find((key) => FONT_SCOPE_CONFIG[key].customFamily === name); return scope ? FONT_SCOPE_CONFIG[scope].family : name; } function mergeSharedFontProfile(profileValues, sharedValues) { const values = mergeSharedProfileValues(profileValues, sharedValues, FONT_PROFILE_VALUE_KEYS, (name) => readerProfileSettingShared('font', fontProfileSharingField(name))); return normalizeFontProfile(values, profileValues); } function currentFontProfile(profile = 'full') { const safeProfile = READER_PROFILE_KEYS.includes(profile) ? profile : 'full'; const values = currentFontProfiles()[safeProfile]; return readerProfileSharingEnabled('font') ? mergeSharedFontProfile(values, currentSharedFontProfile()) : values; } function currentPerformanceConfig() { return performanceConfigFromPrefs(PREFS); } function setPerformancePreset(name) { const preset = PERFORMANCE_PRESETS[name]; if (!preset) return; setPrefs(performancePrefsPatch(preset, name)); } function setPerformanceValue(name, value) { const prefKey = PERFORMANCE_PREF_KEYS[name]; if (!prefKey) return; const current = currentPerformanceConfig(); const next = normalizePerformanceValue(name, value, current[name]); setPrefs({ [prefKey]: next }); } function normalizeImageScalePreset(value) { if (value === 'custom') return 'custom'; const numeric = Number(value); return IMAGE_SCALE_OPTIONS.includes(numeric) ? String(numeric) : '100'; } function normalizeRoundedRange(value, fallback, minimum, maximum) { const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(maximum, Math.max(minimum, Math.round(numeric))); } function normalizeImageScalePercent(value, fallback = 100) { return normalizeRoundedRange(value, fallback, IMAGE_SCALE_MIN, IMAGE_SCALE_MAX); } function normalizeImageProfile(source, fallback = IMAGE_PROFILE_DEFAULTS.full) { const values = source || {}; return { preset: normalizeImageScalePreset(values.preset == null ? fallback.preset : values.preset), custom: normalizeImageScalePercent(values.custom, fallback.custom), }; } function currentImageProfiles() { return normalizeImageProfiles(PREFS.imageProfiles); } function currentImageProfile(profile = 'full') { const safeProfile = READER_PROFILE_KEYS.includes(profile) ? profile : 'full'; return readerProfileSettingShared('image', 'scale') ? normalizeImageProfile(PREFS.sharedImageProfile, IMAGE_PROFILE_DEFAULTS.full) : currentImageProfiles()[safeProfile]; } function currentImageScalePercent(profile = 'full') { const values = currentImageProfile(profile); const preset = normalizeImageScalePreset(values.preset); if (preset === 'custom') return normalizeImageScalePercent(values.custom, 100); return normalizeImageScalePercent(preset, 100); } function normalizeFontScale(value, fallback) { return normalizeRoundedRange(value, fallback, FONT_SCALE_MIN, FONT_SCALE_MAX); } function normalizeHostEmbeddedSizeScale(value, fallback = HOST_EMBED_SIZE_DEFAULT) { return normalizeRoundedRange(value, fallback, HOST_EMBED_SIZE_MIN, HOST_EMBED_SIZE_MAX); } function scaledHostEmbeddedPixels(base, percent) { return `${Math.round(base * normalizeHostEmbeddedSizeScale(percent) * 10 / 100) / 10}px`; } function applyHostEmbeddedSizeSettings() { const style = document.documentElement.style; Object.values(HOST_EMBED_SIZE_SETTINGS).forEach((setting) => { setting.properties.forEach(([property, basePixels]) => { style.setProperty(property, scaledHostEmbeddedPixels(basePixels, PREFS[setting.pref])); }); }); } function applyHostFontAppearance() { const style = document.documentElement.style; const family = normalizeFontFamily(PREFS.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily); const color = normalizeHexColor(PREFS.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor); if (family === 'site') style.removeProperty('--ldp-host-font-family'); else style.setProperty('--ldp-host-font-family', resolvedFontFamily(PREFS, 'host')); style.setProperty('--ldp-host-font-weight', String(normalizeFontWeight(PREFS.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight))); if (color) style.setProperty('--ldp-host-font-color', color); else style.removeProperty('--ldp-host-font-color'); } function normalizeFontFamily(value, fallback = FONT_FAMILY_DEFAULT) { return Object.prototype.hasOwnProperty.call(FONT_FAMILY_OPTIONS, value) ? value : fallback; } function normalizeCustomFontFamily(value, fallback = '') { const source = String(value == null ? fallback : value) .replace(/[\u0000-\u001f\u007f"'`,;{}<>\\]/g, '') .replace(/\s+/g, ' ') .trim(); return Array.from(source).slice(0, 64).join(''); } function normalizeFontWeight(value, fallback = FONT_WEIGHT_DEFAULT) { const numeric = Number(value); return FONT_WEIGHT_OPTIONS.includes(numeric) ? numeric : fallback; } function normalizeHexColor(value, fallback = '') { const color = String(value || '').trim().toLowerCase(); if (/^#[0-9a-f]{6}$/.test(color)) return color; const fallbackColor = String(fallback || '').trim().toLowerCase(); return /^#[0-9a-f]{6}$/.test(fallbackColor) ? fallbackColor : ''; } function resolvedSiteFontFamily() { try { const target = document.body || document.documentElement; const family = target && getComputedStyle(target).fontFamily; return family && family !== 'inherit' ? family : 'inherit'; } catch (e) { return 'inherit'; } } function resolvedFontFamily(values, scope = 'interface') { const config = scope === 'host' ? HOST_FONT_CONFIG : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface; const family = normalizeFontFamily(values && values[config.family]); if (family !== 'custom') return family === 'site' ? resolvedSiteFontFamily() : FONT_FAMILY_OPTIONS[family].stack; const customFamily = normalizeCustomFontFamily(values && values[config.customFamily]); return customFamily ? `${JSON.stringify(customFamily)},${FONT_FAMILY_OPTIONS.system.stack}` : FONT_FAMILY_OPTIONS.system.stack; } function applyReaderFontAppearance(styleDeclaration, values) { if (!styleDeclaration) return; FONT_SCOPE_KEYS.forEach((scope) => { const config = FONT_SCOPE_CONFIG[scope]; const prefix = `--ldp-${scope}-font`; const color = normalizeHexColor(values && values[config.color]); styleDeclaration.setProperty(`${prefix}-family`, resolvedFontFamily(values, scope)); styleDeclaration.setProperty(`${prefix}-weight`, String(normalizeFontWeight(values && values[config.weight]))); if (color) styleDeclaration.setProperty(`${prefix}-color`, color); else styleDeclaration.removeProperty(`${prefix}-color`); }); styleDeclaration.setProperty('--ldp-reader-font-weight-base', String(normalizeFontWeight(values && values.weight))); } function clearReaderFontAppearance(styleDeclaration) { if (!styleDeclaration) return; FONT_SCOPE_KEYS.forEach((scope) => { const prefix = `--ldp-${scope}-font`; styleDeclaration.removeProperty(`${prefix}-family`); styleDeclaration.removeProperty(`${prefix}-weight`); styleDeclaration.removeProperty(`${prefix}-color`); }); styleDeclaration.removeProperty('--ldp-reader-font-weight-base'); } function normalizeFontProfile(source, fallback = FONT_PROFILE_DEFAULTS.full) { const values = source || {}; const profile = {}; const interfaceConfig = FONT_SCOPE_CONFIG.interface; FONT_SCOPE_KEYS.forEach((scope) => { const config = FONT_SCOPE_CONFIG[scope]; const inherited = scope === 'interface' ? fallback : profile; profile[config.family] = normalizeFontFamily(values[config.family], inherited[interfaceConfig.family]); profile[config.customFamily] = normalizeCustomFontFamily(values[config.customFamily], inherited[interfaceConfig.customFamily]); profile[config.weight] = normalizeFontWeight(values[config.weight], inherited[interfaceConfig.weight]); profile[config.color] = normalizeHexColor(values[config.color], fallback[config.color]); profile[config.scale] = normalizeFontScale(values[config.scale], fallback[config.scale]); }); return profile; } function readerProfileForDimensions(width, height) { const safeWidth = Number(width); const safeHeight = Number(height); if (!(safeWidth > 0) || !(safeHeight > 0)) return 'popup'; return safeWidth / safeHeight <= EMBEDDED_MOBILE_MAX_ASPECT_RATIO ? 'mobile' : 'popup'; } function currentReaderProfileName() { const overlay = readerPortalQuery('.ldp-overlay'); if (isEmbeddedListReaderMode(overlay?.dataset.readerWorkspaceMode)) { const modal = overlay.querySelector('.ldp-modal'); const width = Number(overlay.dataset.readerWorkspaceWidth) || modal?.clientWidth || currentListReaderEmbedWidth(); const height = modal?.clientHeight || window.innerHeight; return readerProfileForDimensions(width, height); } if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return 'mobile'; if (overlay?.classList.contains('ldp-fullpage')) return 'full'; const profile = overlay?.dataset.fontProfile || overlay?.dataset.layoutProfile; if (READER_PROFILE_KEYS.includes(profile)) return profile; return overlay ? 'popup' : 'full'; } function fontScalePixels(basePixels, value, fallback = FONT_SCALE_DEFAULT) { const scale = normalizeFontScale(value, fallback) / 100; return `${Math.round(basePixels * scale * 100) / 100}px`; } function applyInterfaceFontScale(styleDeclaration, value) { if (!styleDeclaration) return; const scale = normalizeFontScale(value, FONT_SCALE_DEFAULT); Object.entries(INTERFACE_FONT_TOKEN_BASES).forEach(([property, basePixels]) => { styleDeclaration.setProperty(property, fontScalePixels(basePixels, scale)); }); } function clearInterfaceFontScale(styleDeclaration) { if (!styleDeclaration) return; Object.keys(INTERFACE_FONT_TOKEN_BASES).forEach((property) => styleDeclaration.removeProperty(property)); } function normalizeZebraColor(value) { return normalizeHexColor(value, ZEBRA_COLOR_DEFAULT); } function normalizeSteppedValue(value, fallback, minimum, maximum, step) { const parsed = Number(value); const safeValue = Number.isFinite(parsed) ? parsed : fallback; return Math.min(maximum, Math.max(minimum, Math.round(safeValue / step) * step)); } function normalizeAppearanceProfile(source, fallback = APPEARANCE_PROFILE_DEFAULTS.full) { const values = source || {}; return APPEARANCE_SETTING_FIELDS.reduce((profile, field) => { const value = field.range ? values[field.key] : values[field.key] || fallback[field.key]; profile[field.key] = field.normalize(value, fallback[field.key]); return profile; }, {}); } function currentAppearanceProfiles() { return normalizeAppearanceProfiles(PREFS.appearanceProfiles); } function currentSharedAppearanceProfile() { return normalizeAppearanceProfile(PREFS.sharedAppearanceProfile, APPEARANCE_PROFILE_DEFAULTS.full); } function currentAppearanceProfile(profile = currentReaderProfileName()) { const safeProfile = READER_PROFILE_KEYS.includes(profile) ? profile : 'full'; const values = currentAppearanceProfiles()[safeProfile]; if (!readerProfileSharingEnabled('appearance')) return values; return normalizeAppearanceProfile(mergeSharedProfileValues( values, currentSharedAppearanceProfile(), APPEARANCE_PROFILE_VALUE_KEYS, (key) => readerProfileSettingShared('appearance', key) ), APPEARANCE_PROFILE_DEFAULTS[safeProfile]); } function normalizeAccentColor(value) { return normalizeHexColor(value, ACCENT_COLOR_DEFAULT); } function normalizeLinkColor(value) { return normalizeHexColor(value, LINK_COLOR_DEFAULT); } function appearanceInteractionColors(values, mode = currentReaderThemeMode()) { const appearance = values || APPEARANCE_PROFILE_DEFAULTS.full; const resolved = resolvedReaderThemeMode(mode); const accentSource = normalizeAccentColor(appearance.accentColor); const linkSource = normalizeLinkColor(appearance.linkColor); const accent = resolved === 'dark' && accentSource === ACCENT_COLOR_DEFAULT ? READER_THEME_VARIABLES.dark['--tertiary'] : accentSource; const link = resolved === 'dark' && linkSource === LINK_COLOR_DEFAULT ? READER_THEME_VARIABLES.dark['--d-link-color'] : linkSource; const accentLow = accentSource === ACCENT_COLOR_DEFAULT ? READER_THEME_VARIABLES[resolved]['--tertiary-low'] : `color-mix(in srgb,${accent} 18%,var(--secondary,#fff))`; return { accent, accentLow, link }; } function applyAppearanceInteractionColors(node, values = currentAppearanceProfile(), mode = currentReaderThemeMode()) { if (!node || !node.style) return; const colors = appearanceInteractionColors(values, mode); node.style.setProperty('--tertiary', colors.accent); node.style.setProperty('--tertiary-low', colors.accentLow); node.style.setProperty('--d-link-color', colors.link); } function normalizeLineWidth(value, fallback) { return normalizeSteppedValue(value, fallback, LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP); } function normalizeReplyLineRadius(value, fallback = REPLY_LINE_RADIUS_DEFAULT) { return normalizeSteppedValue( value, fallback, REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP ); } function applyComposerFontScale(value = currentFontProfile(currentReaderProfileName()).composer) { const scale = normalizeFontScale(value, COMPOSER_FONT_SCALE_DEFAULT); document.documentElement.style.setProperty('--ldp-composer-font-size', `${scale}%`); } applyComposerFontScale(); function setPageScrollLocked(locked) { document.documentElement.classList.toggle(LDP_SCROLL_LOCK_CLASS, locked); } function currentListReaderMode() { return normalizeListReaderMode(PREFS.listReaderMode); } function currentTopicReaderMode() { return normalizeTopicReaderMode(PREFS.topicReaderMode); } function currentListReaderEmbedWidth() { return normalizeListReaderEmbedWidth(PREFS.listReaderEmbedWidth); } function isEmbeddedListReaderMode(mode) { return mode === 'embed-left' || mode === 'embed-right'; } function chromeExtensionRuntime() { try { if (typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id || typeof chrome.runtime.sendMessage !== 'function') return null; return chrome.runtime; } catch (e) { return null; } } function sendChromeExtensionMessage(message) { const runtime = chromeExtensionRuntime(); if (!runtime) return Promise.resolve({ ok: false, reason: 'extension-unavailable' }); return new Promise((resolve) => { try { runtime.sendMessage(message, (response) => { const error = runtime.lastError; if (error) resolve({ ok: false, reason: error.message || 'extension-message-failed' }); else resolve(response && typeof response === 'object' ? response : { ok: false, reason: 'empty-response' }); }); } catch (e) { resolve({ ok: false, reason: e && e.message ? e.message : 'extension-message-failed' }); } }); } function setChromeSidePanelActive(active) { document.documentElement.classList.toggle('ldp-reader-sidepanel-active', !!active); document.documentElement.classList.toggle('ldp-reader-workspace', !!active); } async function closeChromeSidePanel() { const result = await sendChromeExtensionMessage({ type: 'ldp-sidepanel-close' }); setChromeSidePanelActive(false); return result || { ok: false, reason: 'sidepanel-close-failed' }; } async function closeChromeSidePanelForReader() { const result = await closeChromeSidePanel(); if (!chromeExtensionRuntime() || result.ok) return true; showSelectionToast('请先关闭 Chrome 侧边栏,再打开浮窗阅读器'); return false; } function installChromeSidePanelBridge() { const runtime = chromeExtensionRuntime(); if (!runtime || !runtime.onMessage || typeof runtime.onMessage.addListener !== 'function') return; runtime.onMessage.addListener((message) => { if (!message || message.type !== 'ldp-sidepanel-state') return; setChromeSidePanelActive(!!message.active); }); } installChromeSidePanelBridge(); function userProfileUrl(username) { return `${BASE}/u/${encodeURIComponent(username || '')}`; } function userActivityUrl(username, path) { const suffix = String(path || '').replace(/^\/+/, ''); return suffix ? `${userProfileUrl(username)}/${suffix}` : userProfileUrl(username); } function topicNavText(value) { if (value == null) return ''; if (typeof value === 'object') return String(value.name || value.slug || value.id || ''); return String(value); } function cleanTopicCategoryName(value) { return topicNavText(value) .replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, '') .replace(/\s+/g, ' ') .trim(); } function safeTopicNavIconHtml(value) { const html = String(value || '').trim(); if (!/^<(?:svg|img)\b/i.test(html)) return ''; if (/<(?:script|foreignObject)\b/i.test(html) || /\son[a-z:-]*\s*=/i.test(html) || /(?:href|src)\s*=\s*["']?\s*javascript:/i.test(html)) return ''; return html; } function topicNavIconCacheKeys(type, value) { if (type === 'tag') { const name = topicNavText(value && typeof value === 'object' ? value.name : value).trim().toLocaleLowerCase(); return name ? [`tag:${name}`] : []; } const metadata = value && typeof value === 'object' ? value : { categoryName: value }; const keys = []; const categoryId = Number(metadata.categoryId || metadata.category_id) || 0; const categorySlug = topicNavText(metadata.categorySlug || metadata.category_slug).trim().toLocaleLowerCase(); const categoryName = cleanTopicCategoryName(metadata.categoryName || metadata.category_name).toLocaleLowerCase(); if (categoryId) keys.push(`category:id:${categoryId}`); if (categorySlug) keys.push(`category:slug:${categorySlug}`); if (categoryName) keys.push(`category:name:${categoryName}`); return keys; } function rememberTopicNavIcon(type, value, iconHtml) { const html = safeTopicNavIconHtml(iconHtml); const keys = topicNavIconCacheKeys(type, value); if (!html || !keys.length) return ''; let changed = false; keys.forEach((key) => { const existing = TOPIC_NAV_ICON_CACHE.get(key); TOPIC_NAV_ICON_CACHE.delete(key); if (existing && existing.html === html) { TOPIC_NAV_ICON_CACHE.set(key, existing); return; } TOPIC_NAV_ICON_CACHE.set(key, { html, at: Date.now() }); changed = true; }); if (changed) { trimPersistentCacheMap('topicNavIcons'); schedulePersistentCacheWrite('topicNavIcons', 500); } return html; } function cachedTopicNavIcon(type, value) { const keys = topicNavIconCacheKeys(type, value); for (const key of keys) { const entry = TOPIC_NAV_ICON_CACHE.get(key); if (!entry) continue; const html = safeTopicNavIconHtml(entry.html); if (!html) { TOPIC_NAV_ICON_CACHE.delete(key); schedulePersistentCacheWrite('topicNavIcons'); continue; } TOPIC_NAV_ICON_CACHE.delete(key); TOPIC_NAV_ICON_CACHE.set(key, entry); return html; } return ''; } function topicNavMetadataWithCachedIcons(metadata = {}) { const tagIcons = new Map(); (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => { const name = topicNavText(item && item.name).trim(); const iconHtml = safeTopicNavIconHtml(item && item.iconHtml); if (name && iconHtml) tagIcons.set(name, { name, iconHtml }); }); (Array.isArray(metadata.tags) ? metadata.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean) .forEach((name) => { if (tagIcons.has(name)) return; const iconHtml = cachedTopicNavIcon('tag', name); if (iconHtml) tagIcons.set(name, { name, iconHtml }); }); return Object.assign({}, metadata, { categoryIconHtml: safeTopicNavIconHtml(metadata.categoryIconHtml) || cachedTopicNavIcon('category', metadata), tagIcons: Array.from(tagIcons.values()), }); } function cacheTopicNavMetadataIcons(metadata = {}) { rememberTopicNavIcon('category', metadata, metadata.categoryIconHtml); (Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => { rememberTopicNavIcon('tag', item && item.name, item && item.iconHtml); }); } function topicCategoryLevel(value) { const match = topicNavText(value).trim().match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i); return match ? `Lv${match[1]}` : ''; } function topicNavIconHtml(link) { if (!link) return ''; return Array.from(link.querySelectorAll('svg,img')) .filter((node) => node.closest('a') === link) .map((node) => { const copy = node.cloneNode(true); copy.querySelectorAll('script,foreignObject').forEach((unsafe) => unsafe.remove()); [copy, ...copy.querySelectorAll('*')].forEach((part) => { Array.from(part.attributes || []).forEach((attribute) => { if (/^on/i.test(attribute.name)) part.removeAttribute(attribute.name); }); }); copy.setAttribute('aria-hidden', 'true'); if (copy.tagName === 'IMG') copy.setAttribute('alt', ''); return copy.outerHTML; }) .join(''); } function topicCategoryLinkMetadata(link) { if (!link) return {}; let categoryId = Number(link.dataset.categoryId || 0) || 0; let categorySlug = String(link.dataset.categorySlug || '').trim(); try { const url = new URL(link.getAttribute('href') || link.href || '', location.href); const parts = url.pathname.split('/').filter(Boolean); const categoryIndex = parts.indexOf('c'); const categoryParts = categoryIndex >= 0 ? parts.slice(categoryIndex + 1) : []; const idIndex = categoryParts.findIndex((part) => /^\d+$/.test(part)); if (!categoryId && idIndex >= 0) categoryId = Number(categoryParts[idIndex]) || 0; if (!categorySlug && categoryParts.length) { const slugPart = categoryParts.find((part, index) => index !== idIndex && !/^\d+$/.test(part)); if (slugPart) categorySlug = decodeURIComponent(slugPart); } } catch (e) {} const nameNode = link.querySelector('[data-category-name],.badge-category__name,.category-name'); const categoryText = (nameNode && (nameNode.dataset.categoryName || nameNode.textContent)) || link.dataset.categoryName || link.getAttribute('title') || link.textContent || ''; const categoryName = cleanTopicCategoryName(categoryText); const categoryLevel = topicCategoryLevel(categoryText) || topicCategoryLevel(link.textContent) || topicCategoryLevel(link.getAttribute('title')); return { categoryName, categoryLevel, categorySlug, categoryId, categoryIconHtml: topicNavIconHtml(link) }; } function readTopicNavMetadata(sourceElement = null, expectedTopicId = 0) { const source = sourceElement && sourceElement.nodeType === Node.ELEMENT_NODE ? sourceElement : null; let root = null; if (source && !source.closest('.ldp-overlay')) { root = source.closest('tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link') || source.parentElement; } if (!root) { const currentRoute = extractTopicRouteFromUrl(location.href); if (expectedTopicId && (!currentRoute || Number(currentRoute.topicId) !== Number(expectedTopicId))) return {}; root = Array.from(document.querySelectorAll('#topic-title,.topic-title,.title-wrapper')) .find((node) => !node.closest('.ldp-overlay')) || null; } if (!root) return {}; const categoryLink = root.querySelector( 'a.badge-category__wrapper,a.badge-category,a.topic-category,a[href^="/c/"],a[href*="/c/"]', ); const metadata = topicCategoryLinkMetadata(categoryLink); const tagLinks = Array.from(root.querySelectorAll('a.discourse-tag,.discourse-tags a,.topic-tags a')) .filter((node) => !node.closest('.ldp-overlay')); metadata.tags = tagLinks .map((node) => topicNavText(node.dataset.tagName || node.textContent).trim()) .filter(Boolean); metadata.tagIcons = tagLinks.map((node) => ({ name: topicNavText(node.dataset.tagName || node.textContent).trim(), iconHtml: topicNavIconHtml(node), })).filter((item) => item.name && item.iconHtml); cacheTopicNavMetadataIcons(metadata); return topicNavMetadataWithCachedIcons(metadata); } function mergeTopicNavMetadata(primary = {}, fallback = {}) { const primaryCategoryName = cleanTopicCategoryName(primary.categoryName || ''); const fallbackCategoryName = cleanTopicCategoryName(fallback.categoryName || ''); const sameCategory = !primaryCategoryName || !fallbackCategoryName || primaryCategoryName === fallbackCategoryName; const tagIcons = new Map(); [fallback.tagIcons, primary.tagIcons].forEach((items) => { (Array.isArray(items) ? items : []).forEach((item) => { const name = topicNavText(item && item.name).trim(); const iconHtml = String(item && item.iconHtml || '').trim(); if (name && iconHtml) tagIcons.set(name, { name, iconHtml }); }); }); const primaryTags = Array.isArray(primary.tags) ? primary.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : []; const fallbackTags = Array.isArray(fallback.tags) ? fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : []; return topicNavMetadataWithCachedIcons({ categoryName: primaryCategoryName || fallbackCategoryName, categoryLevel: topicCategoryLevel(primary.categoryLevel || fallback.categoryLevel || ''), categorySlug: topicNavText(primary.categorySlug || fallback.categorySlug || '').trim(), categoryId: Number(primary.categoryId || fallback.categoryId) || 0, categoryIconHtml: String(primary.categoryIconHtml || (sameCategory ? fallback.categoryIconHtml : '') || '').trim(), tags: primaryTags.length ? primaryTags : fallbackTags, tagIcons: Array.from(tagIcons.values()), }); } function topicNavMetadataHasIcons(metadata) { return !!(metadata && (metadata.categoryIconHtml || (Array.isArray(metadata.tagIcons) && metadata.tagIcons.length))); } function cachedTopicNavMetadata(topic) { if (!topic) return {}; const category = topic.category || {}; const categoryName = cleanTopicCategoryName( topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug, ); const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name] .map(topicCategoryLevel).find(Boolean) || ''; const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug).trim(); const categoryId = Number(topic.category_id || topic.categoryId || category.id) || 0; const tags = (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean); const wantedTags = new Set(tags); let cached = {}; const collect = (candidate) => { if (!candidate || typeof candidate !== 'object') return; const matchingCategory = categoryName && cleanTopicCategoryName(candidate.categoryName) === categoryName; cached = mergeTopicNavMetadata(cached, { categoryName, categoryLevel, categorySlug, categoryId, categoryIconHtml: matchingCategory ? candidate.categoryIconHtml : '', tags, tagIcons: (Array.isArray(candidate.tagIcons) ? candidate.tagIcons : []) .filter((item) => wantedTags.has(topicNavText(item && item.name).trim())), }); }; collect(topic._ldpTopicNavMetadata); TOPIC_DATA_CACHE.forEach((entry) => collect(entry && entry.topic && entry.topic._ldpTopicNavMetadata)); return mergeTopicNavMetadata({ categoryName, categoryLevel, categorySlug, categoryId, tags }, cached); } function rememberTopicNavMetadata(topicId, topic, metadata) { if (!topic || !topicNavMetadataHasIcons(metadata)) return false; cacheTopicNavMetadataIcons(metadata); const cacheable = cachedTopicNavMetadata(topic); const next = mergeTopicNavMetadata(metadata, cacheable); if (!topicNavMetadataHasIcons(next)) return false; const previous = topic._ldpTopicNavMetadata || {}; if (JSON.stringify(previous) === JSON.stringify(next)) return false; topic._ldpTopicNavMetadata = next; setCachedTopicData(topicId, topic); return true; } function fillTopicNavMetadata(topic, fallback = {}) { if (!topic) return false; const category = topic.category || {}; const existingId = Number(topic.category_id || topic.categoryId || discourseModelValue(category, 'id')) || 0; const categoryName = cleanTopicCategoryName( topic.category_name || topic.categoryName || discourseModelValue(category, 'name') || fallback.categoryName || '', ); const categoryLevel = [ topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, discourseModelValue(category, 'name'), fallback.categoryLevel, ].map(topicCategoryLevel).find(Boolean) || ''; const categorySlug = topicNavText( topic.category_slug || topic.categorySlug || discourseModelValue(category, 'slug') || fallback.categorySlug || '', ).trim(); const categoryId = existingId || Number(fallback.categoryId) || 0; let changed = false; if (categoryName && topic.category_name !== categoryName) { topic.category_name = categoryName; changed = true; } if (categoryLevel && topic.category_level !== categoryLevel) { topic.category_level = categoryLevel; changed = true; } if (categorySlug && topic.category_slug !== categorySlug) { topic.category_slug = categorySlug; changed = true; } if (categoryId && Number(topic.category_id) !== categoryId) { topic.category_id = categoryId; changed = true; } if ((!Array.isArray(topic.tags) || !topic.tags.length) && Array.isArray(fallback.tags) && fallback.tags.length) { topic.tags = Array.from(new Set(fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean))); changed = true; } return changed; } function renderTopicNavLinks(topic, metadata = {}) { const items = []; const category = topic.category || {}; const categoryName = cleanTopicCategoryName(topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug); const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name] .map(topicCategoryLevel).find(Boolean) || ''; const categoryLabel = categoryName && categoryLevel ? `${categoryName}, ${categoryLevel}` : categoryName; const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug); const categoryId = topicNavText(topic.category_id || topic.categoryId || category.id); const navMetadata = topicNavMetadataWithCachedIcons(Object.assign({}, metadata, { categoryName, categorySlug, categoryId, tags: (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean), })); let categoryHref = ''; if (categorySlug && categoryId) categoryHref = `${BASE}/c/${encodeURIComponent(categorySlug)}/${encodeURIComponent(categoryId)}`; else if (categorySlug) categoryHref = `${BASE}/c/${encodeURIComponent(categorySlug)}`; else if (categoryId) categoryHref = `${BASE}/c/${encodeURIComponent(categoryId)}`; if (categoryLabel) { const categoryIconHtml = navMetadata.categoryIconHtml || ''; const categoryTextHtml = `<span class="ldp-topic-category-text"><span class="ldp-topic-category-name">${esc(categoryName || categoryLabel)}</span>${categoryLevel ? `<span class="ldp-topic-category-level">, ${esc(categoryLevel)}</span>` : ''}</span>`; items.push(categoryHref ? `<a class="ldp-topic-tag ldp-topic-category" href="${categoryHref}" aria-label="${escAttr(categoryLabel)}">${categoryIconHtml}${categoryTextHtml}</a>` : `<span class="ldp-topic-tag ldp-topic-category" aria-label="${escAttr(categoryLabel)}">${categoryIconHtml}${categoryTextHtml}</span>`); } (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean).forEach((tag) => { const tagIcon = (Array.isArray(navMetadata.tagIcons) ? navMetadata.tagIcons : []) .find((item) => item && item.name === tag); items.push(`<a class="ldp-topic-tag ldp-topic-label" href="${BASE}/tag/${encodeURIComponent(tag)}" aria-label="${escAttr(tag)}">${tagIcon ? tagIcon.iconHtml : ''}${esc(tag)}</a>`); }); return items.join(''); } function avatarUrl(template, size) { if (!template) return ''; const url = String(template).replace('{size}', String(size || 48)); if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:')) return url; return BASE + url; } function persistentAvatarHtml(template, size, className, fallbackText = '?') { const source = avatarUrl(template, size); if (!source) return ''; const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source); if (cachedSource || source.startsWith('data:') || source.startsWith('blob:')) { return `<img class="${escAttr(className)}" src="${escAttr(cachedSource || source)}" alt="" loading="lazy" decoding="async">`; } return `<span class="${escAttr(className)} ldp-persistent-avatar-fallback" data-ldp-avatar-template="${escAttr(template)}" data-ldp-avatar-size="${Math.max(1, Number(size) || 48)}" data-ldp-avatar-class="${escAttr(className)}">${esc(String(fallbackText || '?').slice(0, 1).toUpperCase())}</span>`; } function safeFlairColor(value) { const color = String(value || '').trim(); if (!/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?(?:[0-9a-f]{2})?$/i.test(color)) return ''; return color.startsWith('#') ? color : `#${color}`; } function userFlairHtml(user) { const rawUrl = String(user && ( user.flair_url || user.flairUrl || user.avatar_flair_url || user.avatarFlairUrl ) || '').trim(); if (!rawUrl) return ''; const label = String(user && ( user.flair_name || user.flairName || user.primary_group_name || user.primaryGroupName ) || '资质').trim(); const background = safeFlairColor(user && (user.flair_bg_color || user.flairBgColor)); const color = safeFlairColor(user && (user.flair_color || user.flairColor)); const style = [ background ? `--ldp-flair-bg:${background}` : '', color ? `--ldp-flair-color:${color}` : '', ].filter(Boolean).join(';'); const styleAttr = style ? ` style="${style}"` : ''; if (/^(?:https?:)?\/\//i.test(rawUrl) || rawUrl.startsWith('/') || rawUrl.startsWith('data:image/')) { const source = absoluteImageUrl(rawUrl); return `<span class="ldp-avatar-flair" data-ldp-tooltip-label="${escAttr(label)}" aria-label="${escAttr(label)}"${styleAttr}><img class="ldp-avatar-flair-image" src="${escAttr(source)}" alt="" loading="lazy" decoding="async"></span>`; } const iconId = rawUrl.replace(/^fa-/, ''); if (!/^[a-z0-9_-]+$/i.test(iconId)) return ''; return `<span class="ldp-avatar-flair" data-ldp-tooltip-label="${escAttr(label)}" aria-label="${escAttr(label)}"${styleAttr}><svg class="ldp-avatar-flair-icon" aria-hidden="true"><use href="#${escAttr(iconId)}"></use></svg></span>`; } function avatarWithFlairHtml(avatarHtml, user) { if (!avatarHtml) return ''; return `<span class="ldp-avatar-with-flair">${avatarHtml}${userFlairHtml(user)}</span>`; } function userCardAvatarHtml(avatarHtml, username) { const cleanUsername = cleanUsernameValue(username); if (!avatarHtml || !cleanUsername) return avatarHtml; return `<span class="ldp-user-avatar-card" data-user-card="${escAttr(cleanUsername)}">${avatarHtml}</span>`; } async function waitForReaderRequestPermit(priority, options = {}) { const queuedAt = requestFlowNow(); const callSite = requestFlowCallSite(); let scheduler = options.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER; if (!scheduler) return { queuedAt, permittedAt: queuedAt, waitReason: '', callSite, scheduler }; let untrackController = options.controller && scheduler !== options.trackedScheduler && typeof scheduler.trackExternalController === 'function' ? scheduler.trackExternalController(options.controller) : () => {}; for (let attempt = 0; attempt < 2; attempt++) { try { const permit = await scheduler.waitForPermit(priority, options); return { queuedAt, permittedAt: Number(permit && permit.startedAt) || requestFlowNow(), waitReason: String(permit && permit.waitReason || ''), callSite, recoveryProbe: permit && permit.recoveryProbe === true, scheduler, release: () => { if (typeof permit?.release === 'function') permit.release(); untrackController(); }, }; } catch (error) { const replacementScheduler = ACTIVE_READER_REQUEST_SCHEDULER; if (attempt > 0 || options.signal?.aborted || !error || error.name !== 'AbortError' || !replacementScheduler || replacementScheduler === scheduler) { untrackController(); throw error; } untrackController(); scheduler = replacementScheduler; untrackController = options.controller && typeof scheduler.trackExternalController === 'function' ? scheduler.trackExternalController(options.controller) : () => {}; } } } function persistentResourceResponseIsFresh(response, maxAge, at = Date.now()) { const cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0; return !!response && cachedAt > 0 && at - cachedAt <= maxAge; } function createPersistentResourceResponse(blob, contentType = '') { const headers = { 'content-type': blob.type || contentType || 'application/octet-stream', 'content-length': String(blob.size), }; headers['x-ldp-cached-at'] = String(Date.now()); return new Response(blob, { headers }); } async function persistentResourceCacheEntry(cacheName, source, maxAge) { const storage = await caches.open(cacheName); const response = await storage.match(source); if (response && !persistentResourceResponseIsFresh(response, maxAge)) { await storage.delete(source); return { storage, response: null }; } return { storage, response: response || null }; } async function prunePersistentResourceCache(cacheName, maxAge) { try { const storage = await caches.open(cacheName); const now = Date.now(); const requests = await storage.keys(); const entries = []; await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => { const response = await storage.match(request); if (!persistentResourceResponseIsFresh(response, maxAge, now)) { await storage.delete(request); return; } entries.push({ request, cachedAt: Number(response.headers.get('x-ldp-cached-at')) || 0, bytes: Math.max(0, Number(response.headers.get('content-length')) || 0), }); }); const group = RESOURCE_CACHE_GROUPS.find((entry) => entry.cacheName === cacheName); const maxEntries = Math.max(0, Number(group && group.maxEntries) || 0); const maxBytes = Math.max(0, Number(group && group.maxBytes) || 0); if (!maxEntries && !maxBytes) return; let keptEntries = 0; let keptBytes = 0; const removeRequests = []; entries.sort((left, right) => right.cachedAt - left.cachedAt).forEach((entry) => { const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries; const exceedsBytes = maxBytes > 0 && keptBytes + entry.bytes > maxBytes; if (exceedsEntries || exceedsBytes) { removeRequests.push(entry.request); return; } keptEntries++; keptBytes += entry.bytes; }); await runInBatches(removeRequests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, (request) => storage.delete(request)); } catch { // Persistent resource cleanup is best-effort and must not block the reader. } } function prunePersistentResourceCacheOnce(cacheName, maxAge) { if (typeof caches === 'undefined') return Promise.resolve(); if (!RESOURCE_CACHE_PRUNE_REQUESTS.has(cacheName)) { RESOURCE_CACHE_PRUNE_REQUESTS.set(cacheName, prunePersistentResourceCache(cacheName, maxAge)); } return RESOURCE_CACHE_PRUNE_REQUESTS.get(cacheName); } function cacheResourceObjectUrl(cache, source, objectUrl, maxEntries) { if (cache.has(source)) { URL.revokeObjectURL(cache.get(source)); cache.delete(source); } while (cache.size >= maxEntries) { const oldestSource = cache.keys().next().value; const oldestUrl = cache.get(oldestSource); if (oldestUrl) URL.revokeObjectURL(oldestUrl); cache.delete(oldestSource); } cache.set(source, objectUrl); return objectUrl; } function touchResourceObjectUrl(cache, source) { if (!cache.has(source)) return ''; const objectUrl = cache.get(source); cache.delete(source); cache.set(source, objectUrl); return objectUrl; } function clearResourceObjectUrlCache(cache) { cache.forEach((objectUrl) => { if (objectUrl) URL.revokeObjectURL(objectUrl); }); cache.clear(); } function trackSingleFlightRequest(requests, key, request) { const trackedRequest = request.finally(() => { if (requests.get(key) === trackedRequest) requests.delete(key); }); requests.set(key, trackedRequest); return trackedRequest; } function trackResourceCacheWrite(request) { const trackedRequest = Promise.resolve(request).catch(() => {}).finally(() => { RESOURCE_CACHE_WRITES.delete(trackedRequest); }); RESOURCE_CACHE_WRITES.add(trackedRequest); return trackedRequest; } async function fetchReaderPublicResource(source, priority, type, signal) { const flowPermit = await waitForReaderRequestPermit(priority, { countsTowardApi: false, type, signal, }); if (!flowPermit) return null; const { release, ...requestFlowMeta } = flowPermit; try { const response = await fetch(source, { // Public assets may redirect to a wildcard-CORS CDN, which is incompatible with credentials. credentials: 'omit', cache: 'force-cache', signal, [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type, ...requestFlowMeta }, }); return { response, release: typeof release === 'function' ? release : () => {} }; } catch (error) { if (typeof release === 'function') release(); throw error; } } async function avatarResponseBlob(response, onProgress) { if (typeof onProgress !== 'function' || !response.body || typeof response.body.getReader !== 'function') { const blob = await response.blob(); if (typeof onProgress === 'function') onProgress(blob.size, blob.size); return blob; } const total = Math.max(0, Number(response.headers.get('content-length')) || 0); const reader = response.body.getReader(); const chunks = []; let loaded = 0; onProgress(0, total); while (true) { const { done, value } = await reader.read(); if (done) break; if (!value || !value.byteLength) continue; chunks.push(value); loaded += value.byteLength; onProgress(loaded, total); } const bytes = new Uint8Array(loaded); let offset = 0; chunks.forEach((chunk) => { bytes.set(chunk, offset); offset += chunk.byteLength; }); const blob = await new Response(bytes, { headers: { 'content-type': response.headers.get('content-type') || 'application/octet-stream' }, }).blob(); onProgress(loaded, total || loaded); return blob; } async function persistentAvatarSource(template, size = 64, priority = POST_REQUEST_PRIORITY.visible, options = {}) { const source = avatarUrl(template, size); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const completeProgress = () => onProgress && onProgress(1, 1); if (!source || source.startsWith('data:') || source.startsWith('blob:')) { if (source) completeProgress(); return source; } if (RESOURCE_CACHE_CLEARING) return source; const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal; const cachedObjectUrl = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source); if (cachedObjectUrl) { completeProgress(); return cachedObjectUrl; } if (AVATAR_RESOURCE_REQUESTS.has(source)) { const sharedSource = await AVATAR_RESOURCE_REQUESTS.get(source); if (sharedSource) completeProgress(); return sharedSource; } const request = (async () => { if (typeof caches === 'undefined') return source; try { let { storage, response } = await persistentResourceCacheEntry( LDP_AVATAR_CACHE_NAME, source, LDP_AVATAR_CACHE_MAX_AGE ); let blob = null; if (!response) { const resource = await fetchReaderPublicResource( new URL(source, location.href).href, priority, 'avatar', resourceCacheSignal ); if (!resource) return ''; try { const fetched = resource.response; // Keep uncached avatar starts in the reader cadence. If Cache Storage // cannot fill them, let the browser load the URL normally. if (!fetched.ok) return source; if (options.rejectBlankAvatar) { const contentType = fetched.headers.get('content-type') || ''; const lastModified = Date.parse(fetched.headers.get('last-modified') || ''); const isDiscourseBlank = Number.isFinite(lastModified) && new Date(lastModified).getUTCFullYear() === 1990; if ((contentType && !contentType.startsWith('image/')) || isDiscourseBlank) return ''; } blob = await avatarResponseBlob(fetched, onProgress); if (!blob.size || resourceCacheSignal.aborted) return source; response = createPersistentResourceResponse(blob, fetched.headers.get('content-type')); void trackResourceCacheWrite(storage.put(source, response.clone())); } finally { resource.release(); } } if (!blob) blob = await avatarResponseBlob(response, onProgress); if (!blob.size || resourceCacheSignal.aborted) return source; return cacheResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 256); } catch (error) { return source; } })(); return trackSingleFlightRequest(AVATAR_RESOURCE_REQUESTS, source, request); } function replacePersistentAvatarPlaceholder(placeholder, source) { if (!source || !placeholder || !placeholder.isConnected) return; const image = document.createElement('img'); image.className = placeholder.dataset.ldpAvatarClass || (placeholder.classList.contains('ldp-notification-avatar') ? 'ldp-notification-avatar' : 'ldp-avatar'); image.src = source; image.alt = ''; image.loading = 'lazy'; image.decoding = 'async'; image.dataset.ldpAvatarOriginalTemplate = placeholder.dataset.ldpAvatarTemplate || ''; replaceOwnedNode(placeholder, image); } function hydratePersistentAvatarImages(container, priority = POST_REQUEST_PRIORITY.visible) { if (!container) return; container.querySelectorAll('[data-ldp-avatar-template]').forEach((placeholder) => { if (!placeholder.isConnected || AVATAR_HYDRATION_PENDING.has(placeholder)) return; const template = placeholder.dataset.ldpAvatarTemplate; if (!template) return; AVATAR_HYDRATION_PENDING.add(placeholder); persistentAvatarSource(template, Number(placeholder.dataset.ldpAvatarSize) || 64, priority) .then((source) => replacePersistentAvatarPlaceholder(placeholder, source)) .finally(() => AVATAR_HYDRATION_PENDING.delete(placeholder)); }); } function releasePersistentAvatarImages(container) { if (!container) return; const placeholders = new Set(container.querySelectorAll('[data-ldp-avatar-template]')); if (container.matches && container.matches('[data-ldp-avatar-template]')) placeholders.add(container); placeholders.forEach((placeholder) => { AVATAR_HYDRATION_PENDING.delete(placeholder); }); } function userAvatarDownloadFilename(source, username, kind = 'avatar') { let extension = 'png'; try { const match = new URL(source, location.href).pathname.match(/\.([a-z0-9]{2,5})$/i); if (match && /^(?:png|apng|jpe?g|gif|webp|avif|svg|bmp|ico)$/i.test(match[1])) extension = match[1].toLowerCase(); } catch (error) { void error; } const basename = String(username || 'user').replace(/[^a-z0-9._-]+/gi, '-') || 'user'; return `${basename}-${kind === 'background' ? 'background' : 'avatar'}.${extension === 'jpeg' ? 'jpg' : extension}`; } function createImageTransformController(stage, image, options = {}) { const captureTarget = options.captureTarget || stage; let scale = 1; let panX = 0; let panY = 0; let containedPan = false; let pointerId = null; let dragX = 0; let dragY = 0; let pendingPanX = 0; let pendingPanY = 0; let dragFrame = 0; const clampPan = () => { if (!image.clientWidth || !image.clientHeight) { panX = 0; panY = 0; return; } const padding = Math.max(0, Number(options.overflowPadding) || 0); const scaledWidth = image.clientWidth * scale; const scaledHeight = image.clientHeight * scale; const maxX = scaledWidth > stage.clientWidth ? (scaledWidth - stage.clientWidth) / 2 + padding : containedPan ? Math.max(0, (stage.clientWidth - scaledWidth) / 2 - padding) : 0; const maxY = scaledHeight > stage.clientHeight ? (scaledHeight - stage.clientHeight) / 2 + padding : containedPan ? Math.max(0, (stage.clientHeight - scaledHeight) / 2 - padding) : 0; panX = Math.max(-maxX, Math.min(maxX, panX)); panY = Math.max(-maxY, Math.min(maxY, panY)); }; const render = () => { clampPan(); options.render({ scale, panX, panY }); image.classList.toggle('is-zoomed', scale > 1.01); if (options.zoomValue) options.zoomValue.textContent = `${Math.round(scale * 100)}%`; if (options.zoomOutButton) options.zoomOutButton.disabled = scale <= 0.25; if (options.zoomInButton) options.zoomInButton.disabled = scale >= 8; }; const setZoom = (value, clientX = null, clientY = null) => { const nextScale = Math.max(0.25, Math.min(8, Number(value) || 1)); const anchored = Number.isFinite(clientX) && Number.isFinite(clientY) && image.clientWidth && image.clientHeight; if (anchored && nextScale !== scale) { const imageRect = image.getBoundingClientRect(); const scaleRatio = nextScale / scale; panX += (clientX - (imageRect.left + imageRect.width / 2)) * (1 - scaleRatio); panY += (clientY - (imageRect.top + imageRect.height / 2)) * (1 - scaleRatio); } containedPan = options.allowContainedPan === true && anchored; scale = nextScale; if (options.resetPanAtFit !== false && scale <= 1.01) panX = panY = 0; render(); }; const reset = () => { scale = 1; panX = panY = 0; containedPan = false; render(); }; const flushDrag = () => { dragFrame = 0; panX = pendingPanX; panY = pendingPanY; render(); }; const onPointerDown = (event) => { if (scale <= 1.01 || event.button !== 0 || event.target !== image) return; pointerId = event.pointerId; dragX = event.clientX - panX; dragY = event.clientY - panY; pendingPanX = panX; pendingPanY = panY; captureTarget.setPointerCapture(pointerId); image.classList.add('is-dragging'); if (options.preventDragDefault) event.preventDefault(); }; const onPointerMove = (event) => { if (pointerId !== event.pointerId) return; pendingPanX = event.clientX - dragX; pendingPanY = event.clientY - dragY; if (!dragFrame) dragFrame = requestAnimationFrame(flushDrag); }; const onPointerEnd = (event) => { if (pointerId !== event.pointerId) return; if (dragFrame) { cancelAnimationFrame(dragFrame); flushDrag(); } if (captureTarget.hasPointerCapture(pointerId)) captureTarget.releasePointerCapture(pointerId); pointerId = null; image.classList.remove('is-dragging'); }; captureTarget.addEventListener('pointerdown', onPointerDown); captureTarget.addEventListener('pointermove', onPointerMove); captureTarget.addEventListener('pointerup', onPointerEnd); captureTarget.addEventListener('pointercancel', onPointerEnd); return { get scale() { return scale; }, render, setZoom, reset, destroy() { if (dragFrame) cancelAnimationFrame(dragFrame); captureTarget.removeEventListener('pointerdown', onPointerDown); captureTarget.removeEventListener('pointermove', onPointerMove); captureTarget.removeEventListener('pointerup', onPointerEnd); captureTarget.removeEventListener('pointercancel', onPointerEnd); }, }; } function handleImageTransformShortcut(event, controller, reset = controller.reset) { if (event.key === '+' || event.key === '=') { controller.setZoom(controller.scale * 1.2); } else if (event.key === '-') { controller.setZoom(controller.scale / 1.2); } else if (event.key === '0') { reset(); } else return false; event.preventDefault(); return true; } function positionUserAvatarViewer(viewer, anchor) { if (!viewer || !viewer.isConnected) return; const margin = 10; const gap = 10; const rect = anchor && anchor.isConnected ? anchor.getBoundingClientRect() : null; const imageViewerSize = Math.min(480, window.innerWidth - margin * 2, window.innerHeight - margin * 2); const width = viewer.offsetWidth || (viewer.classList.contains('is-image') ? imageViewerSize : 320); const height = viewer.offsetHeight || (viewer.classList.contains('is-image') ? imageViewerSize : 358); const batchDialog = viewer.classList.contains('is-image') && anchor && anchor.closest ? anchor.closest('.ldp-lb-batch-dialog') : null; if (batchDialog) { batchDialog.style.removeProperty('transform'); const batchRect = batchDialog.getBoundingClientRect(); const combinedWidth = batchRect.width + gap + width; if (combinedWidth <= window.innerWidth - margin * 2) { const groupLeft = Math.max(margin, Math.round((window.innerWidth - combinedWidth) / 2)); batchDialog.style.transform = `translateX(${Math.round(groupLeft - batchRect.left)}px)`; viewer.style.left = `${Math.round(groupLeft + batchRect.width + gap)}px`; viewer.style.top = `${Math.round(batchRect.top)}px`; viewer.style.height = `${Math.round(batchRect.height)}px`; viewer._ldpBatchDialog = batchDialog; return; } } viewer.style.removeProperty('height'); let left = Math.max(margin, Math.round((window.innerWidth - width) / 2)); let top = Math.max(margin, Math.round((window.innerHeight - height) / 2)); if (rect) { const rightSide = rect.right + gap; const leftSide = rect.left - width - gap; if (rightSide + width <= window.innerWidth - margin) left = rightSide; else if (leftSide >= margin) left = leftSide; else left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin)); top = Math.max(margin, Math.min(rect.top, window.innerHeight - height - margin)); } viewer.style.left = `${Math.round(left)}px`; viewer.style.top = `${Math.round(top)}px`; } function openUserAvatarViewer(template, username, anchorCard, previewSource = '', onDismiss = null, user = null, options = {}) { const isBackground = options.kind === 'background'; const isImage = options.kind === 'image'; const highResolutionSource = isBackground || isImage ? String(options.source || '').trim() : avatarUrl(template, 1000); const loadImageOriginal = isImage && options.loadOriginal === true && Boolean(highResolutionSource); if (!highResolutionSource && !previewSource) return null; const currentViewer = readerPortalQuery('.ldp-avatar-viewer'); if (currentViewer && isImage && options.reuseImageViewer === true && currentViewer._ldpKind === 'image' && typeof currentViewer._ldpUpdateImage === 'function') { currentViewer._ldpUpdateImage({ template, previewSource, highResolutionSource, options }); return currentViewer; } if (currentViewer && currentViewer._ldpUsername === username && currentViewer._ldpTemplate === template && currentViewer._ldpKind === (isBackground ? 'background' : isImage ? 'image' : 'avatar')) { positionUserAvatarViewer(currentViewer, anchorCard); return currentViewer; } if (currentViewer && typeof currentViewer._ldpClose === 'function') currentViewer._ldpClose(); const viewer = document.createElement('div'); const mediaLabel = isBackground ? '背景图' : isImage ? '图片' : '头像'; const accessibleName = username ? `${username} 的${mediaLabel}预览` : `${mediaLabel}预览`; viewer.className = `ldp-avatar-viewer${isBackground ? ' is-background' : isImage ? ' is-image' : ''}`; viewer.setAttribute('role', 'dialog'); viewer.setAttribute('aria-label', accessibleName); viewer.innerHTML = ` <div class="ldp-avatar-viewer-toolbar" role="toolbar" aria-label="${mediaLabel}工具"> ${isImage && options.selectable ? `<label class="ldp-avatar-viewer-selection"><input type="checkbox"${options.selected ? ' checked' : ''}><span>${esc(options.selectionLabel || '选择当前图片')}</span></label>` : ''} <div class="ldp-avatar-viewer-progress is-indeterminate" role="progressbar" aria-label="${mediaLabel}原图加载进度" aria-valuemin="0" aria-valuemax="100" aria-valuetext="正在加载${mediaLabel}原图"${highResolutionSource && !isBackground && (!isImage || loadImageOriginal) ? '' : ' hidden'}> <span class="ldp-avatar-viewer-progress-track" aria-hidden="true"><span class="ldp-avatar-viewer-progress-fill"></span></span> <span class="ldp-avatar-viewer-progress-value">原图加载中</span> </div> ${isImage ? `<button class="ldp-lb-btn" type="button" data-avatar-viewer-action="zoom-out" aria-label="缩小(-)">${icon('minus')}</button> <button class="ldp-lb-btn ldp-avatar-viewer-zoom-value" type="button" data-avatar-viewer-action="zoom-reset" aria-label="恢复 100%">100%</button> <button class="ldp-lb-btn" type="button" data-avatar-viewer-action="zoom-in" aria-label="放大(+)">${icon('plus')}</button>` : ''} <button class="ldp-lb-btn" type="button" data-avatar-viewer-action="download" aria-label="下载当前${mediaLabel}" disabled>${icon('download')}</button> <button class="ldp-lb-btn" type="button" data-avatar-viewer-action="close" aria-label="关闭${mediaLabel}预览(Esc)">${icon('x')}</button> </div> <div class="ldp-avatar-viewer-stage"> ${isImage && typeof options.onPrevious === 'function' ? `<button class="ldp-avatar-viewer-nav ldp-avatar-viewer-prev" type="button" data-avatar-viewer-action="previous" aria-label="上一张(←)"${options.previousDisabled ? ' disabled' : ''}>${icon('chevronRight')}</button>` : ''} <img class="ldp-avatar-viewer-image" alt="${escAttr(accessibleName)}" draggable="false" decoding="async" hidden> ${isImage && typeof options.onNext === 'function' ? `<button class="ldp-avatar-viewer-nav ldp-avatar-viewer-next" type="button" data-avatar-viewer-action="next" aria-label="下一张(→)"${options.nextDisabled ? ' disabled' : ''}>${icon('chevronRight')}</button>` : ''} ${isBackground || isImage ? '' : userFlairHtml(user)} <div class="ldp-avatar-viewer-status" role="status" aria-live="polite">正在加载${mediaLabel}…</div> </div>`; const image = viewer.querySelector('.ldp-avatar-viewer-image'); const stage = viewer.querySelector('.ldp-avatar-viewer-stage'); const status = viewer.querySelector('.ldp-avatar-viewer-status'); const progress = viewer.querySelector('.ldp-avatar-viewer-progress'); const progressValue = viewer.querySelector('.ldp-avatar-viewer-progress-value'); const downloadButton = viewer.querySelector('[data-avatar-viewer-action="download"]'); const selectionInput = viewer.querySelector('.ldp-avatar-viewer-selection input'); const selectionLabel = viewer.querySelector('.ldp-avatar-viewer-selection span'); const previousButton = viewer.querySelector('[data-avatar-viewer-action="previous"]'); const nextButton = viewer.querySelector('[data-avatar-viewer-action="next"]'); const zoomValue = viewer.querySelector('.ldp-avatar-viewer-zoom-value'); const zoomOutButton = viewer.querySelector('[data-avatar-viewer-action="zoom-out"]'); const zoomInButton = viewer.querySelector('[data-avatar-viewer-action="zoom-in"]'); let activeOptions = options; let activePreviewSource = previewSource; let activeHighResolutionSource = highResolutionSource; let imageSourceToken = 0; let displayedSource = ''; let loadingHighResolution = false; let highResolutionPending = Boolean(highResolutionSource) && !isBackground && (!isImage || loadImageOriginal); let closed = false; const imageTransform = isImage ? createImageTransformController(stage, image, { captureTarget: image, preventDragDefault: true, zoomValue, zoomOutButton, zoomInButton, render: ({ scale, panX, panY }) => { image.style.transform = `translate3d(${Math.round(panX)}px,${Math.round(panY)}px,0) scale(${scale})`; }, }) : null; const close = (dismissed = false) => { if (closed) return; closed = true; document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onOutside, true); document.removeEventListener('wheel', onViewerWheel, true); window.removeEventListener('resize', reposition); if (imageTransform) imageTransform.destroy(); if (viewer._ldpBatchDialog) viewer._ldpBatchDialog.style.removeProperty('transform'); viewer.remove(); if (dismissed && typeof onDismiss === 'function') onDismiss(); }; const download = () => { if (typeof activeOptions.onDownload === 'function') { void activeOptions.onDownload(); return; } const link = document.createElement('a'); link.href = displayedSource; link.download = userAvatarDownloadFilename(activeHighResolutionSource || activePreviewSource, username, isBackground ? 'background' : 'avatar'); link.target = '_blank'; link.rel = 'noopener'; appendReaderPortalNode(link); link.click(); link.remove(); }; function onKey(event) { if (!viewer.isConnected) return; if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); close(true); } else if (isImage && event.key === 'ArrowLeft' && typeof activeOptions.onPrevious === 'function' && !activeOptions.previousDisabled) { event.preventDefault(); event.stopImmediatePropagation(); activeOptions.onPrevious(); } else if (isImage && event.key === 'ArrowRight' && typeof activeOptions.onNext === 'function' && !activeOptions.nextDisabled) { event.preventDefault(); event.stopImmediatePropagation(); activeOptions.onNext(); } else if (isImage && handleImageTransformShortcut(event, imageTransform)) { event.stopImmediatePropagation(); } } function onOutside(event) { if (eventPathIncludes(event, viewer) || eventPathIncludes(event, anchorCard)) return; const batchDialog = viewer._ldpBatchDialog || (anchorCard && anchorCard.closest && anchorCard.closest('.ldp-lb-batch-dialog')); if (isImage && activeOptions.reuseImageViewer === true && batchDialog && eventPathIncludes(event, batchDialog)) return; close(true); } function onViewerWheel(event) { if (!viewer.isConnected || !isImage || !eventPathIncludes(event, viewer)) return; event.preventDefault(); event.stopImmediatePropagation(); const zoomAtPointer = event.target === image; imageTransform.setZoom( imageTransform.scale * (event.deltaY < 0 ? 1.15 : 1 / 1.15), zoomAtPointer ? event.clientX : null, zoomAtPointer ? event.clientY : null ); } function reposition() { positionUserAvatarViewer(viewer, anchorCard); } function updateProgress(loaded, total) { if (closed || !highResolutionPending) return; const hasTotal = Number.isFinite(total) && total > 0; const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) : null; progress.hidden = false; progress.classList.toggle('is-indeterminate', percent === null); if (percent === null) { progress.style.removeProperty('--ldp-avatar-progress'); progress.removeAttribute('aria-valuenow'); progress.setAttribute('aria-valuetext', `正在加载${mediaLabel}原图`); progressValue.textContent = `${mediaLabel}原图加载中`; return; } progress.style.setProperty('--ldp-avatar-progress', `${percent}%`); progress.setAttribute('aria-valuenow', String(percent)); progress.setAttribute('aria-valuetext', `${mediaLabel}原图已加载 ${percent}%`); progressValue.textContent = `原图 ${percent}%`; } function finishProgress() { highResolutionPending = false; progress.hidden = true; } const loadViewerImageSources = (nextPreviewSource, nextHighResolutionSource, shouldLoadOriginal) => { const token = ++imageSourceToken; activePreviewSource = String(nextPreviewSource || ''); activeHighResolutionSource = String(nextHighResolutionSource || ''); loadingHighResolution = false; highResolutionPending = Boolean(activeHighResolutionSource) && shouldLoadOriginal === true; progress.hidden = !highResolutionPending; progress.classList.add('is-indeterminate'); progress.removeAttribute('aria-valuenow'); progressValue.textContent = '原图加载中'; displayedSource = ''; downloadButton.disabled = true; status.hidden = Boolean(activePreviewSource); if (activePreviewSource) image.src = activePreviewSource; imageTransform.reset(); if (!highResolutionPending) return; void persistentLightboxImageSource(activeHighResolutionSource).then((source) => { if (closed || token !== imageSourceToken) return; if (!source || source === activeHighResolutionSource) { finishProgress(); return; } loadingHighResolution = true; image.src = source; }).catch(() => { if (!closed && token === imageSourceToken) finishProgress(); }); }; viewer._ldpClose = close; viewer._ldpUsername = username; viewer._ldpTemplate = template; viewer._ldpKind = isBackground ? 'background' : isImage ? 'image' : 'avatar'; viewer._ldpAnchorCard = anchorCard; viewer._ldpUpdateImage = (next) => { activeOptions = next.options || {}; viewer._ldpTemplate = next.template; if (selectionInput) selectionInput.checked = activeOptions.selected === true; if (selectionLabel) selectionLabel.textContent = activeOptions.selectionLabel || '选择当前图片'; if (previousButton) previousButton.disabled = activeOptions.previousDisabled === true; if (nextButton) nextButton.disabled = activeOptions.nextDisabled === true; loadViewerImageSources(next.previewSource, next.highResolutionSource, activeOptions.loadOriginal === true); }; viewer.addEventListener('click', (event) => { const action = event.target.closest && event.target.closest('[data-avatar-viewer-action]')?.dataset.avatarViewerAction; if (action === 'download') download(); else if (action === 'close') close(true); else if (action === 'previous' && typeof activeOptions.onPrevious === 'function') activeOptions.onPrevious(); else if (action === 'next' && typeof activeOptions.onNext === 'function') activeOptions.onNext(); else if (action === 'zoom-out') imageTransform.setZoom(imageTransform.scale / 1.2); else if (action === 'zoom-in') imageTransform.setZoom(imageTransform.scale * 1.2); else if (action === 'zoom-reset') imageTransform.reset(); }); if (selectionInput) selectionInput.addEventListener('change', () => { if (typeof activeOptions.onSelectionChange === 'function') activeOptions.onSelectionChange(selectionInput.checked); }); image.addEventListener('load', () => { if (closed) return; const completedHighResolution = loadingHighResolution; displayedSource = image.currentSrc || image.src; loadingHighResolution = false; if (completedHighResolution) finishProgress(); image.hidden = false; status.hidden = true; downloadButton.disabled = false; if (!isImage) reposition(); }); image.addEventListener('error', () => { if (closed) return; if (loadingHighResolution && activePreviewSource) { loadingHighResolution = false; finishProgress(); image.src = activePreviewSource; return; } if (loadingHighResolution) finishProgress(); if (highResolutionPending) { image.hidden = true; status.hidden = false; status.textContent = `正在加载${mediaLabel}原图…`; return; } image.hidden = true; status.hidden = false; status.textContent = `未获取到可用${mediaLabel}`; downloadButton.disabled = true; }); if (isImage) { stage.addEventListener('dblclick', (event) => { if (event.target !== image) return; imageTransform.setZoom( imageTransform.scale > 1.05 ? 1 : Math.max(2, Math.min(8, image.naturalWidth / Math.max(1, image.clientWidth))), event.clientX, event.clientY ); }); } document.addEventListener('keydown', onKey); appendReaderPortalNode(viewer); syncReaderSvgSymbols(viewer); reposition(); if (imageTransform) imageTransform.render(); document.addEventListener('pointerdown', onOutside, true); if (isImage) document.addEventListener('wheel', onViewerWheel, { capture: true, passive: false }); window.addEventListener('resize', reposition); if (isBackground) { loadingHighResolution = true; image.src = highResolutionSource; return viewer; } if (isImage) { loadViewerImageSources(previewSource, highResolutionSource, loadImageOriginal); return viewer; } if (previewSource) image.src = previewSource; if (!highResolutionSource) return viewer; void persistentAvatarSource(template, 1000, POST_REQUEST_PRIORITY.auxiliary, { rejectBlankAvatar: true, onProgress: updateProgress, }).then((source) => { if (closed) return; if (!source) { finishProgress(); if (!previewSource) status.textContent = '未获取到高清头像'; return; } loadingHighResolution = true; image.src = source; }); return viewer; } function fmtTime(iso) { if (!iso) return ''; const t = new Date(iso).getTime(); if (isNaN(t)) return ''; const diff = Date.now() - t; const min = 60000, hour = 60 * min, day = 24 * hour; if (diff < min) return '刚刚'; if (diff < hour) return Math.floor(diff / min) + ' 分钟前'; if (diff < day) return Math.floor(diff / hour) + ' 小时前'; if (diff < 30 * day) return Math.floor(diff / day) + ' 天前'; const d = new Date(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } function fmtExactTime(iso) { const d = new Date(iso); if (!Number.isFinite(d.getTime())) return ''; const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } function refreshPostRelativeTimes(container) { if (!container) return; container.querySelectorAll('.ldp-time-relative').forEach((relativeEl) => { const createdAt = relativeEl.closest('.ldp-post')?.dataset.createdAt || ''; const time = fmtTime(createdAt); if (!time) return; const nextText = `· ${time}`; if (relativeEl.textContent !== nextText) relativeEl.textContent = nextText; }); } let CACHED_CSRF_TOKEN = ''; let CSRF_TOKEN_REQUEST = null; async function csrfToken() { const pageToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; if (pageToken) return pageToken; if (CACHED_CSRF_TOKEN) return CACHED_CSRF_TOKEN; if (!CSRF_TOKEN_REQUEST) { CSRF_TOKEN_REQUEST = fetchJSON(`${BASE}/session/csrf`, { key: 'session:csrf', priority: POST_REQUEST_PRIORITY.visible, cacheMode: 'bypass', bypassScheduler: true, }).then((data) => { CACHED_CSRF_TOKEN = String(data && data.csrf || ''); if (!CACHED_CSRF_TOKEN) throw new Error('无法获取 CSRF Token'); return CACHED_CSRF_TOKEN; }).finally(() => { CSRF_TOKEN_REQUEST = null; }); } return CSRF_TOKEN_REQUEST; } function createRequestAbortScope(externalSignal) { const controller = new AbortController(); let timeout = 0; let timedOut = false; const abortFromExternal = () => controller.abort(externalSignal && externalSignal.reason); if (externalSignal) { if (externalSignal.aborted) abortFromExternal(); else externalSignal.addEventListener('abort', abortFromExternal, { once: true }); } return { controller, signal: controller.signal, startTimeout(delay = POST_REQUEST_TIMEOUT) { if (timeout) clearTimeout(timeout); if (controller.signal.aborted) return; timeout = setTimeout(() => { timedOut = true; controller.abort(); }, Math.max(1, Number(delay) || POST_REQUEST_TIMEOUT)); }, stopTimeout() { if (timeout) clearTimeout(timeout); timeout = 0; }, timedOut() { return timedOut; }, cleanup() { if (timeout) clearTimeout(timeout); timeout = 0; if (externalSignal) externalSignal.removeEventListener('abort', abortFromExternal); }, }; } function requestStatusError(message, status) { const error = new Error(message); error.status = status; return error; } function requestTimeoutError(message = '请求超时') { return requestStatusError(message, 408); } function acquireSharedReaderRequestPermit(priority, signal, type, scheduler) { if (!SHARED_REQUEST_COORDINATOR) return null; let requestState = null; try { requestState = scheduler && scheduler.snapshot(); } catch (error) {} return SHARED_REQUEST_COORDINATOR.acquirePermit({ priority, signal, shortBudget: requestState ? requestState.shortWindowBudget : 40, longBudget: requestState ? requestState.longWindowBudget : 160, minInterval: requestState ? requestState.minInterval : 90, maxConcurrent: requestState ? requestState.maxConcurrent : 3, type, }); } async function fetchJSON(url, options = {}) { const scheduler = options.scheduler || ACTIVE_READER_REQUEST_SCHEDULER; const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible; const requestCallSite = requestFlowCallSite(); const fetchOptions = { credentials: 'include', headers: { 'Accept': 'application/json' }, ...(options.fetchOptions || {}), }; fetchOptions.headers = Object.assign( { 'Accept': 'application/json' }, options.fetchOptions && options.fetchOptions.headers || {} ); if (options.signal) fetchOptions.signal = options.signal; return requestCachedJSON(url, fetchOptions, options, async () => { if (scheduler && options.bypassScheduler !== true) { return scheduler.requestJSON(url, fetchOptions, { key: options.key || `reader:${url}`, priority, callSite: requestCallSite, }); } const queuedAt = requestFlowNow(); const requestScope = createRequestAbortScope(fetchOptions.signal); let sharedPermit = null; try { sharedPermit = await acquireSharedReaderRequestPermit( priority, requestScope.signal, requestFlowType(requestFlowUrl(url), 'fetch', fetchOptions.method || 'GET'), scheduler); requestScope.startTimeout(); const res = await fetch(url, { ...fetchOptions, signal: requestScope.signal, [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, recoveryProbe: sharedPermit && sharedPermit.recoveryProbe === true, queuedAt, permittedAt: sharedPermit && sharedPermit.startedAt || requestFlowNow(), waitReason: sharedPermit && sharedPermit.waitReason || '', callSite: requestCallSite, attempt: 1, }, }); noteSharedRequestRateLimit(res); if (!res.ok) { throw requestStatusError('HTTP ' + res.status, res.status); } return res.json(); } catch (error) { if (requestScope.timedOut() && !(fetchOptions.signal && fetchOptions.signal.aborted)) { throw requestTimeoutError(); } throw error; } finally { requestScope.cleanup(); if (sharedPermit && SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId); } } }); } function ensureKatexStyles() { if (chromeExtensionRuntime()) return; if (document.querySelector('link[data-ldp-katex]')) return; const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`; link.dataset.ldpKatex = '1'; (document.head || document.documentElement).appendChild(link); } function latexTokenInfo(token) { if (token.startsWith('$$') && token.endsWith('$$')) return { tex: token.slice(2, -2), displayMode: true }; if (token.startsWith('\\[') && token.endsWith('\\]')) return { tex: token.slice(2, -2), displayMode: true }; if (token.startsWith('\\(') && token.endsWith('\\)')) return { tex: token.slice(2, -2), displayMode: false }; return { tex: token.slice(1, -1), displayMode: false }; } function textContainsLatex(value) { return /\\(?:frac|sum|sqrt|int|prod|lim|begin|left|right|mathbf|mathrm|text)|\$\$|\\\[|\\\(|[_^]\{/.test(value || ''); } function renderLatexInPost(post) { if (!post || post.dataset.ldpLatexRendered === '1') return; if (post.dataset.ldpContentHydrated === '0') return; const content = post.querySelector(':scope > .ldp-content'); const katex = globalThis.katex; if (!content) return; if (!textContainsLatex(content.textContent)) { post.dataset.ldpLatexRendered = '1'; return; } if (!katex) return; ensureKatexStyles(); content.querySelectorAll('p').forEach((paragraph) => { if (paragraph.children.length || paragraph.closest('pre,code')) return; const text = paragraph.textContent.trim(); if (!/\\(?:frac|sum|sqrt|int|prod|lim|begin)|^[A-Za-z][^\n=]{0,40}=/.test(text)) return; try { katex.render(text, paragraph, { displayMode: true, throwOnError: false, strict: 'ignore' }); } catch (error) {} }); const tokenPattern = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$(?!\s)(?:\\.|[^$\\])+?\$)/g; const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); const textNodes = []; while (walker.nextNode()) { const node = walker.currentNode; const parent = node.parentElement; if (!parent || parent.closest('pre,code,a,.katex')) continue; if (tokenPattern.test(node.nodeValue || '')) textNodes.push(node); tokenPattern.lastIndex = 0; } textNodes.forEach((textNode) => { const value = textNode.nodeValue || ''; const fragment = document.createDocumentFragment(); let lastIndex = 0; value.replace(tokenPattern, (token, offset) => { if (offset > lastIndex) fragment.appendChild(document.createTextNode(value.slice(lastIndex, offset))); const info = latexTokenInfo(token); const holder = document.createElement(info.displayMode ? 'div' : 'span'); try { katex.render(info.tex, holder, { displayMode: info.displayMode, throwOnError: false, strict: 'ignore' }); fragment.appendChild(holder); } catch (error) { fragment.appendChild(document.createTextNode(token)); } lastIndex = offset + token.length; return token; }); if (lastIndex < value.length) fragment.appendChild(document.createTextNode(value.slice(lastIndex))); textNode.replaceWith(fragment); tokenPattern.lastIndex = 0; }); post.dataset.ldpLatexRendered = '1'; } function notificationData(notification) { const raw = notification && notification.data; if (!raw) return {}; if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch (e) { return {}; } } function notificationTypeLabel(notification) { if (notification && notification._ldp_type_label) return String(notification._ldp_type_label); const type = Number(notification && notification.notification_type); if ([1, 2, 3, 9, 15].includes(type)) return '回复'; if ([5, 19, 25].includes(type)) return '点赞'; if ([6, 7, 16].includes(type)) return '私信'; return '通知'; } function notificationHref(notification) { if (notification && notification._ldp_href) return String(notification._ldp_href); if (!notification || !notification.topic_id) return `${BASE}/my/notifications`; const data = notificationData(notification); const slug = data.topic_slug || notification.slug || 'topic'; const postNumber = Number(notification.post_number || data.post_number || 1); return `${BASE}/t/${encodeURIComponent(slug)}/${notification.topic_id}/${postNumber}`; } function notificationSummary(notification) { if (notification && notification._ldp_summary) return String(notification._ldp_summary); const data = notificationData(notification); const actor = data.display_username || data.original_username || data.acting_user_name || data.username || notification.username || ''; const title = data.topic_title || notification.fancy_title || notification.topic_title || ''; const type = notificationTypeLabel(notification); if (actor && title) return `${actor} · ${type} · ${title}`; if (actor) return `${actor} · ${type}`; return title || type; } function notificationActor(notification) { if (notification && notification._ldp_actor) return String(notification._ldp_actor); const data = notificationData(notification); return cleanUsernameValue(data.display_username || data.original_username || data.acting_user_name || data.username || notification.username); } function normalizeSearchText(value) { return String(value || '').toLocaleLowerCase().replace(/\s+/g, '').trim(); } function pinyinSearchForms(value) { const source = String(value || ''); if (!source) return []; if (SEARCH_PINYIN_CACHE.has(source)) { const cached = SEARCH_PINYIN_CACHE.get(source); SEARCH_PINYIN_CACHE.delete(source); SEARCH_PINYIN_CACHE.set(source, cached); return cached; } const forms = [normalizeSearchText(source)]; const api = globalThis.pinyinPro; if (api && typeof api.pinyin === 'function') { try { forms.push(normalizeSearchText(api.pinyin(source, { toneType: 'none', nonZh: 'consecutive' }))); forms.push(normalizeSearchText(api.pinyin(source, { pattern: 'first', toneType: 'none', nonZh: 'consecutive', }))); } catch (e) {} } const result = Array.from(new Set(forms.filter(Boolean))); SEARCH_PINYIN_CACHE.set(source, result); while (SEARCH_PINYIN_CACHE.size > SEARCH_PINYIN_CACHE_MAX_ENTRIES) { SEARCH_PINYIN_CACHE.delete(SEARCH_PINYIN_CACHE.keys().next().value); } return result; } function notificationUserIds(notification) { const data = notificationData(notification); return [ notification && notification.user_id, notification && notification.acting_user_id, data.user_id, data.acting_user_id, data.original_user_id, ].filter((value) => value != null && value !== '').map(String); } function notificationStatsIdentity(notification) { return String(notification && notification.id || '') || [ notification && notification.topic_id, notification && notification.post_number, notification && notification.created_at, ].filter(Boolean).join(':'); } function addNotificationStatsIdentities(notificationIds, notifications) { notifications.forEach((notification) => { const notificationId = notificationStatsIdentity(notification); if (notificationId) notificationIds.add(notificationId); }); } function notificationDisplayIdentity(notification) { return String(notification && notification.id || '') || [ notificationHref(notification), notification && notification.created_at, notificationActor(notification), ].join('|'); } function notificationMatchesSearch(notification, query) { const needle = normalizeSearchText(query); if (!needle) return true; const data = notificationData(notification); return [ notificationActor(notification), data.display_username, data.original_username, data.acting_user_name, data.username, notification && notification.username, ...notificationUserIds(notification), ].some((value) => normalizeSearchText(value).includes(needle)); } function historyMatchesSearch(entry, query) { const needle = normalizeSearchText(query); if (!needle) return true; return pinyinSearchForms(entry && entry.title).some((value) => value.includes(needle)); } function reactionRecordMatchesSearch(entry, query) { const needle = normalizeSearchText(query); if (!needle) return true; return [ entry && entry.reaction, entry && entry.authorUsername, entry && entry.postNumber, ...pinyinSearchForms(entry && entry.title), ].some((value) => normalizeSearchText(value).includes(needle)); } function compareGivenReactionRecords(left, right) { return (Date.parse(right && right.reactedAt || '') || 0) - (Date.parse(left && left.reactedAt || '') || 0) || Number(right && right.id || 0) - Number(left && left.id || 0); } function forEachCachedNotification(callback) { NOTIFICATION_CACHE.forEach((entry, cacheKey) => { if (currentAuthCacheKey(cacheKey) && Array.isArray(entry?.value?.notifications)) { entry.value.notifications.forEach(callback); } }); } function cachedNotificationsForGroup(groupKey) { const seen = new Set(); const notifications = []; forEachCachedNotification((notification) => { if (!notificationMatchesGroup(notification, groupKey)) return; const key = notificationDisplayIdentity(notification); if (seen.has(key)) return; seen.add(key); notifications.push(notification); }); return sortNotificationsNewestFirst(notifications); } function notificationMatchesGroup(notification, groupKey) { const group = NOTIFICATION_GROUPS[groupKey] || NOTIFICATION_GROUPS.all; return !group.typeIds.length || group.typeIds.includes(Number(notification && notification.notification_type)); } function currentUserUnreadNotificationCount(currentUser) { if (!currentUser || typeof currentUser !== 'object') return null; const readCount = (key) => { const value = currentUser[key]; if (value == null || value === '') return null; const count = Number(value); if (Number.isFinite(count)) return Math.max(0, Math.floor(count)); return null; }; const allUnread = readCount('all_unread_notifications_count'); if (allUnread != null) return allUnread; const normalUnread = readCount('unread_notifications'); const highPriorityUnread = readCount('unread_high_priority_notifications'); if (normalUnread != null || highPriorityUnread != null) { return (normalUnread || 0) + (highPriorityUnread || 0); } return readCount('unread_notification_count'); } function cachedUnreadNotificationCount(groupKey = 'all') { return cachedNotificationsForGroup(groupKey).reduce((count, notification) => count + (notification && notification.read === false ? 1 : 0), 0); } function syncNotificationUnreadUi(count = NOTIFICATION_UNREAD_COUNT) { NOTIFICATION_UNREAD_COUNT = Math.max(0, Math.floor(Number(count) || 0)); const badgeText = NOTIFICATION_UNREAD_COUNT > 99 ? '99+' : String(NOTIFICATION_UNREAD_COUNT); readerPortalQueryAll('.ldp-notification-unread-badge').forEach((badge) => { badge.hidden = NOTIFICATION_UNREAD_COUNT <= 0; badge.textContent = badgeText; }); readerPortalQueryAll('.ldp-notification-unread-status').forEach((status) => { status.textContent = NOTIFICATION_UNREAD_COUNT > 0 ? `未读 ${NOTIFICATION_UNREAD_COUNT} 条` : '没有未读消息'; }); readerPortalQueryAll('.ldp-notification-mark-all').forEach((button) => { if (button.dataset.busy !== '1') button.disabled = NOTIFICATION_UNREAD_COUNT <= 0; }); } function markCachedNotificationsRead(groupKey = 'all') { const markedNotificationKeys = new Set(); let markedCount = 0; let changed = false; forEachCachedNotification((notification) => { if (!notification || notification.read !== false || !notificationMatchesGroup(notification, groupKey)) return; const notificationKey = notificationDisplayIdentity(notification); if (!markedNotificationKeys.has(notificationKey)) { markedNotificationKeys.add(notificationKey); markedCount++; } notification.read = true; changed = true; }); if (changed) schedulePersistentCacheWrite('notifications', 0); syncNotificationUnreadUi(groupKey === 'all' ? 0 : Math.max(0, NOTIFICATION_UNREAD_COUNT - markedCount)); return markedCount; } function markClickedNotificationRead(item) { if (!item || !item.classList.contains('unread')) return 0; const notificationKey = String(item.dataset.notificationKey || ''); let cacheChanged = false; forEachCachedNotification((notification) => { if (!notification || notification.read !== false || notificationDisplayIdentity(notification) !== notificationKey) return; notification.read = true; cacheChanged = true; }); if (cacheChanged) schedulePersistentCacheWrite('notifications', 0); item.classList.remove('unread'); const readState = item.querySelector('.ldp-notification-read-state'); if (readState) readState.textContent = '已读'; syncNotificationUnreadUi(Math.max(0, NOTIFICATION_UNREAD_COUNT - 1)); if (ME_CURRENT_USER) { ['all_unread_notifications_count', 'unread_notifications', 'unread_notification_count', 'unread_high_priority_notifications'] .forEach((key) => { if (!Object.prototype.hasOwnProperty.call(ME_CURRENT_USER, key)) return; const value = Number(ME_CURRENT_USER[key]); if (Number.isFinite(value)) ME_CURRENT_USER[key] = Math.max(0, Math.floor(value) - 1); }); } return Math.max(0, Number(item.dataset.notificationId) || 0); } function notificationDateGroup(createdAt) { const date = new Date(createdAt || 0); if (Number.isNaN(date.getTime())) return '更早'; const today = new Date(); const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); const startDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); if (startDate === startToday) return '今天'; if (startDate === startToday - 86400000) return '昨天'; return '更早'; } function groupNotificationsByDate(notifications) { const buckets = new Map([['今天', []], ['昨天', []], ['更早', []]]); (notifications || []).forEach((notification) => { buckets.get(notificationDateGroup(notification.created_at)).push(notification); }); return Array.from(buckets.entries()).filter(([, items]) => items.length); } function notificationAvatarHtml(notification) { const data = notificationData(notification); const actor = notificationActor(notification); const template = notification.acting_user_avatar_template || notification.avatar_template || data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor); let avatarHtml; if (template) { const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(template, 64)); avatarHtml = cachedSource ? `<img class="ldp-notification-avatar" src="${escAttr(cachedSource)}" alt="" loading="lazy" decoding="async">` : `<span class="ldp-notification-avatar ldp-notification-avatar-fallback" data-ldp-avatar-template="${escAttr(template)}">${esc(String(actor || '?').slice(0, 1).toUpperCase())}</span>`; } else { avatarHtml = `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${esc(String(actor || '?').slice(0, 1).toUpperCase())}</span>`; } return userCardAvatarHtml(avatarHtml, actor); } function cachedActorAvatar(actor, historySource = readTopicHistory) { actor = cleanUsernameValue(actor); if (!actor) return ''; if (NOTIFICATION_AVATAR_CACHE.has(actor)) { const template = NOTIFICATION_AVATAR_CACHE.get(actor); rememberNotificationAvatar(actor, template); return template; } const cachedUser = getCachedUserCard(actor); let template = String(cachedUser && cachedUser.avatar_template || '').trim(); if (!template) { const actorKey = actor.toLocaleLowerCase(); const historyEntry = historySource().find((entry) => cleanUsernameValue(entry && entry.ownerUsername).toLocaleLowerCase() === actorKey && entry.avatarTemplate ); template = String(historyEntry && historyEntry.avatarTemplate || '').trim(); } if (template) rememberNotificationAvatar(actor, template); return template; } function rememberNotificationAvatar(actor, template) { actor = cleanUsernameValue(actor); template = String(template || '').trim(); if (!actor || !template) return; NOTIFICATION_AVATAR_CACHE.delete(actor); NOTIFICATION_AVATAR_CACHE.set(actor, template); while (NOTIFICATION_AVATAR_CACHE.size > NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES) { NOTIFICATION_AVATAR_CACHE.delete(NOTIFICATION_AVATAR_CACHE.keys().next().value); } } function notificationCacheSnapshot(notification) { if (!notification || typeof notification !== 'object') return notification; const data = notificationData(notification); const snapshot = Object.assign({}, notification, { data, _ldp_actor: notificationActor(notification), _ldp_summary: notificationSummary(notification), _ldp_href: notificationHref(notification), _ldp_type_label: notificationTypeLabel(notification), }); const actor = notificationActor(snapshot); const template = notification.acting_user_avatar_template || notification.avatar_template || data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor) || ''; if (template) { snapshot.avatar_template = template; if (actor) rememberNotificationAvatar(actor, template); } return snapshot; } function persistNotificationActorAvatar(actor, template) { actor = cleanUsernameValue(actor); template = String(template || '').trim(); if (!actor || !template) return; rememberNotificationAvatar(actor, template); let changed = false; forEachCachedNotification((notification) => { if (notificationActor(notification) !== actor || notification.avatar_template === template) return; notification.avatar_template = template; changed = true; }); if (changed) schedulePersistentCacheWrite('notifications', 0); } function hydrateActorAvatarNodes(container, templates, attribute) { if (!container.isConnected || !templates.size) return; container.querySelectorAll(`[${attribute}]`).forEach((item) => { const template = templates.get(item.getAttribute(attribute)); const current = template && item.querySelector('.ldp-notification-avatar'); if (!current || current.tagName === 'IMG') return; current.dataset.ldpAvatarTemplate = template; }); } function cachedActorAvatars(actors, historySource) { const templates = new Map(); actors.forEach((actor) => { const template = cachedActorAvatar(actor, historySource); if (template) templates.set(actor, template); }); return templates; } function hydrateNotificationAvatars(container, notifications) { if (!container) return; const actors = new Set(); (notifications || []).forEach((notification) => { const actor = notificationActor(notification); const data = notificationData(notification); if (actor && !(notification.acting_user_avatar_template || notification.avatar_template || data.acting_user_avatar_template || data.avatar_template)) { actors.add(actor); } }); let history; const templates = cachedActorAvatars(actors, () => history || (history = readTopicHistory())); if (container.isConnected) { templates.forEach((template, actor) => persistNotificationActorAvatar(actor, template)); } hydrateActorAvatarNodes(container, templates, 'data-notification-actor'); hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary); } function hydrateHistoryAvatars(container, entries) { if (!container) return; const actors = new Set(); (entries || []).forEach((entry) => { const actor = cleanUsernameValue(entry && entry.ownerUsername); if (actor && !entry.avatarTemplate) actors.add(actor); }); const history = actors.size ? readTopicHistory() : []; const templates = cachedActorAvatars(actors, () => history); let historyChanged = false; history.forEach((entry) => { const template = templates.get(cleanUsernameValue(entry && entry.ownerUsername)); if (!template || entry.avatarTemplate === template) return; entry.avatarTemplate = template; historyChanged = true; }); if (historyChanged) writeTopicHistory(history); hydrateActorAvatarNodes(container, templates, 'data-history-actor'); hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary); } function renderNotificationList(notifications) { const groups = groupNotificationsByDate(notifications); if (!groups.length) return '<div class="ldp-notification-empty">暂无消息</div>'; return groups.map(([label, items]) => ` <section class="ldp-notification-date-group"> <div class="ldp-notification-date-label">${label}</div> ${items.map((notification) => ` <a class="ldp-notification-item${notification.read ? '' : ' unread'}" data-notification-id="${Math.max(0, Number(notification.id) || 0)}" data-notification-key="${escAttr(notificationDisplayIdentity(notification))}" data-notification-actor="${escAttr(notificationActor(notification))}" data-ldp-preserve-target-post="1" href="${escAttr(notificationHref(notification))}"> ${notificationAvatarHtml(notification)} <span class="ldp-notification-copy"> <span class="ldp-notification-title">${esc(notificationSummary(notification))}</span> <span class="ldp-notification-footer"> <span class="ldp-notification-meta">${esc(fmtTime(notification.created_at))}</span> <span class="ldp-notification-read-state">${notification.read ? '已读' : '未读'}</span> </span> </span> </a>`).join('')} </section>`).join(''); } function getCachedNotificationPage(cacheKey) { const entry = NOTIFICATION_CACHE.get(cacheKey); if (!entry) return null; if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.notifications.maxAge) { NOTIFICATION_CACHE.delete(cacheKey); schedulePersistentCacheWrite('notifications'); return null; } NOTIFICATION_CACHE.delete(cacheKey); NOTIFICATION_CACHE.set(cacheKey, entry); return entry.value || null; } function cachedNotificationPageIsFresh(cacheKey) { const entry = NOTIFICATION_CACHE.get(cacheKey); return !!entry && Date.now() - Number(entry.at || 0) <= NOTIFICATION_CACHE_FRESH_FOR; } function setCachedNotificationPage(cacheKey, value) { if (!cacheKey || !value) return; NOTIFICATION_CACHE.delete(cacheKey); NOTIFICATION_CACHE.set(cacheKey, { value, at: Date.now() }); trimPersistentCacheMap('notifications'); schedulePersistentCacheWrite('notifications', 0); } function notificationPageCacheKey(groupKey, page) { return authScopedCacheKey(`tabs-v3:${groupKey}:${Math.max(0, Number(page) || 0)}`); } function sortNotificationsNewestFirst(notifications) { return (notifications || []).slice().sort((a, b) => { const timeA = Date.parse(a && a.created_at || '') || 0; const timeB = Date.parse(b && b.created_at || '') || 0; return timeB - timeA || Number(b && b.id || 0) - Number(a && a.id || 0); }); } async function fetchNotificationPage(groupKey, page, options = {}) { const safeGroupKey = Object.prototype.hasOwnProperty.call(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all'; page = Math.max(0, Number(page) || 0); const cacheKey = notificationPageCacheKey(safeGroupKey, page); const cached = getCachedNotificationPage(cacheKey); if (cached && !options.force) return cached; if (NOTIFICATION_REQUESTS.has(cacheKey)) return NOTIFICATION_REQUESTS.get(cacheKey); const request = (async () => { const params = new URLSearchParams(); params.set('offset', String(page * NOTIFICATION_PAGE_SIZE)); params.set('limit', String(NOTIFICATION_PAGE_SIZE)); if (safeGroupKey !== 'all') { params.set('recent', 'true'); params.set('silent', 'true'); params.set('filter_by_types', NOTIFICATION_GROUPS[safeGroupKey].types.join(',')); } const data = await fetchJSON(`${BASE}/notifications.json?${params}`, { key: `notifications:${cacheKey}`, priority: POST_REQUEST_PRIORITY.auxiliary, cacheMode: options.force ? 'refresh' : 'default', }); const rawNotifications = Array.isArray(data.notifications) ? data.notifications : []; const notifications = sortNotificationsNewestFirst(rawNotifications .filter((notification) => notificationMatchesGroup(notification, safeGroupKey)) .map(notificationCacheSnapshot)); const offset = page * NOTIFICATION_PAGE_SIZE; const serverTotal = Number(data.total_rows_notifications || 0); const hasNext = !!data.load_more_notifications || (serverTotal > 0 && offset + NOTIFICATION_PAGE_SIZE < serverTotal); const total = serverTotal > 0 ? serverTotal : offset + notifications.length + (hasNext ? NOTIFICATION_PAGE_SIZE : 0); const result = { notifications, total, hasNext }; setCachedNotificationPage(cacheKey, result); return result; })(); return await trackSingleFlightRequest(NOTIFICATION_REQUESTS, cacheKey, request); } function postsFromPayload(data) { if (!data) return []; if (Array.isArray(data)) return data; if (Array.isArray(data.post_stream && data.post_stream.posts)) return data.post_stream.posts; if (Array.isArray(data.posts)) return data.posts; if (data.post && data.post.post_number) return [data.post]; if (data.post_number) return [data]; return []; } function shouldStopReaderFallback(error) { const status = Number(error && error.status || 0); return !!(error && error.name === 'AbortError') || status === 408 || status === 429 || status >= 500; } function retryAfterMs(response, attempt) { const raw = requestFlowHeader(response && response.headers, REQUEST_FLOW_RESPONSE_HEADERS.retryAfter); let wait = 0; if (raw) { const seconds = Number(raw); wait = Number.isFinite(seconds) ? seconds * 1000 : Math.max(0, Date.parse(raw) - Date.now()); } if (!wait) wait = POST_REQUEST_429_BACKOFF * Math.pow(2, Math.max(0, attempt - 1)); return Math.max(1000, Math.min(60000, wait)); } function requestRateLimitWindow(response) { return rateLimitWindowFromCode(requestFlowHeader( response && response.headers, REQUEST_FLOW_RESPONSE_HEADERS.rateLimitCode )); } function noteSharedRequestRateLimit(response, attempt = 1) { if (!SHARED_REQUEST_COORDINATOR || !response || response.status !== 429) return; SHARED_REQUEST_COORDINATOR.noteRateLimit({ wait: retryAfterMs(response, attempt), window: requestRateLimitWindow(response).label, shortBudget: 40, longBudget: 160, }); } function createReaderRequestScheduler(runtimeConfig) { const requestMaxConcurrent = runtimeConfig.requestMaxConcurrent; const requestMinInterval = runtimeConfig.requestMinInterval; const requestRateTarget = Math.min(0.95, Math.max(0.5, runtimeConfig.requestRateTarget / 100)); const shortWindowBudget = Math.max(1, Math.floor(READER_REQUEST_DEFAULT_SHORT_CEILING * requestRateTarget)); const longWindowBudget = Math.max(1, Math.floor(READER_REQUEST_DEFAULT_LONG_CEILING * requestRateTarget)); const queue = []; const tasksByKey = new Map(); const endpointRateLimitCircuits = new Map(); const unknownRateLimitEvidence = []; const activeControllers = new Set(); const pendingPermitControllers = new Set(); const externalControllers = new Set(); let activeCount = 0; let sharedPermitPending = false; let sequence = 0; let responseLatencyEwma = 0; let responseLatencyPeak = 0; let responseLatencySamples = 0; let pumpTimer = 0; let disposed = false; const abortError = (reason = 'Aborted') => new DOMException(String(reason || 'Aborted'), 'AbortError'); const endpointRateLimitIdentity = (input, method = 'GET', routeOnly = false) => { const url = requestFlowUrl(input); const normalizedMethod = String(method || 'GET').toUpperCase(); if (!url) return `${normalizedMethod}:invalid:${String(input || '').slice(0, 160)}`; const path = routeOnly ? url.pathname .replace(/\b\d+\b/g, ':id') .replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':uuid') : url.pathname; const params = Array.from(url.searchParams.entries()) .filter(([key]) => key !== '_ldp_retry') .map(([key, value]) => routeOnly ? [key, ''] : [key, value]) .sort(([aKey, aValue], [bKey, bValue]) => aKey.localeCompare(bKey) || aValue.localeCompare(bValue)); const query = routeOnly ? Array.from(new Set(params.map(([key]) => encodeURIComponent(key)))).join('&') : params.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&'); return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ''}`; }; const pruneEndpointRateLimitState = (at = Date.now()) => { const evidenceCutoff = at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS; while (unknownRateLimitEvidence.length && unknownRateLimitEvidence[0].at < evidenceCutoff) { unknownRateLimitEvidence.shift(); } endpointRateLimitCircuits.forEach((state, key) => { if (state.lastAt < at - READER_ENDPOINT_429_STATE_TTL) endpointRateLimitCircuits.delete(key); }); if (endpointRateLimitCircuits.size > READER_ENDPOINT_429_MAX_ENTRIES) { Array.from(endpointRateLimitCircuits.entries()) .sort(([, a], [, b]) => a.lastAt - b.lastAt) .slice(0, endpointRateLimitCircuits.size - READER_ENDPOINT_429_MAX_ENTRIES) .forEach(([key]) => endpointRateLimitCircuits.delete(key)); } }; const endpointRateLimitState = (fingerprint, at = Date.now()) => { pruneEndpointRateLimitState(at); const state = endpointRateLimitCircuits.get(fingerprint); return state && state.blockedUntil > at ? state : null; }; const noteUnknownEndpointRateLimit = (task, response) => { const at = Date.now(); pruneEndpointRateLimitState(at); const corroboratedGlobal = unknownRateLimitEvidence.some((event) => event.route !== task.endpointRoute && event.at >= at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS); unknownRateLimitEvidence.push({ at, route: task.endpointRoute, }); if (corroboratedGlobal) { endpointRateLimitCircuits.clear(); return null; } const previous = endpointRateLimitCircuits.get(task.endpointFingerprint); const strikes = previous && previous.lastAt >= at - READER_ENDPOINT_429_STATE_TTL ? previous.strikes + 1 : 1; const exponential = READER_ENDPOINT_429_BASE_BLOCK_MS * Math.pow(2, Math.min(6, strikes - 1)); const wait = Math.min( READER_ENDPOINT_429_MAX_BLOCK_MS, Math.max(retryAfterMs(response, strikes), exponential) ); const state = { strikes, lastAt: at, blockedUntil: at + wait }; endpointRateLimitCircuits.set(task.endpointFingerprint, state); return state; }; const latencyBoundConcurrent = () => { if (responseLatencySamples < 6 || !responseLatencyEwma) return requestMaxConcurrent; const latencyBudget = Math.max(responseLatencyEwma * 1.15, responseLatencyPeak); return Math.min(requestMaxConcurrent, Math.max(1, Math.ceil(latencyBudget / requestMinInterval))); }; const effectiveMaxConcurrent = latencyBoundConcurrent; const sortQueue = (at = Date.now()) => queue.sort((a, b) => { const aAge = Math.min(2, Math.floor(Math.max(0, at - a.queuedAt) / 5000)); const bAge = Math.min(2, Math.floor(Math.max(0, at - b.queuedAt) / 5000)); return (a.priority - aAge) - (b.priority - bAge) || a.sequence - b.sequence; }); const schedulePump = () => { if (disposed || pumpTimer || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return; pumpTimer = setTimeout(pump, 0); }; const finishQueuedTask = (task, error) => { if (!task.controlRecorded) { task.controlRecorded = true; const errorReason = error && error.name === 'AbortError' && error.message && error.message !== 'Aborted' ? error.message : ''; const controlReason = String(task.controlReason || errorReason || task.waitReason || (disposed ? 'context-close' : 'cancelled')); const type = task.sharedType || requestFlowType(requestFlowUrl(task.url), 'fetch', task.fetchOptions.method || 'GET'); recordRequestFlowControl(task.url, { queuedAt: task.queuedAt, method: task.fetchOptions.method || 'GET', priority: task.priority, waitReason: task.waitReason || controlReason, callSite: task.callSite, type, path: task.permitOnly ? `等待${REQUEST_FLOW_TYPE_META[type]?.label || '请求'}许可` : '', attempt: task.attempt + 1, controlReason, }); } tasksByKey.delete(task.key); task.state = 'done'; task.reject(error); }; const noteRateLimit = (response, attempt = 1, options = {}) => { if (!response || response.status !== 429) return 0; const wait = retryAfterMs(response, attempt); const rateLimitWindow = options.rateLimitWindow || requestRateLimitWindow(response); if (SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.noteRateLimit({ wait, window: rateLimitWindow.label, shortBudget: shortWindowBudget, longBudget: longWindowBudget, }); } schedulePump(); return wait; }; const runTask = async (task) => { const controller = new AbortController(); const externalSignal = task.signal; const abort = () => controller.abort(); const startedAt = Date.now(); let timedOut = false; const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, POST_REQUEST_TIMEOUT); task.state = 'active'; task.controller = controller; activeCount++; activeControllers.add(controller); if (externalSignal) externalSignal.addEventListener('abort', abort, { once: true }); try { if (task.permitOnly) { tasksByKey.delete(task.key); task.state = 'done'; task.keepSharedPermit = true; const permitId = task.sharedPermitId; task.resolve({ recoveryProbe: task.sharedRecoveryProbe === true, startedAt: task.permittedAt || requestFlowNow(), waitReason: task.waitReason, release: () => { if (!task.keepSharedPermit) return; task.keepSharedPermit = false; if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.finishPermit(permitId); }, }); return; } const response = await fetch(task.url, { ...task.fetchOptions, signal: controller.signal, [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority: task.priority, recoveryProbe: task.sharedRecoveryProbe === true, queuedAt: task.queuedAt, permittedAt: task.permittedAt, waitReason: task.waitReason, callSite: task.callSite, attempt: task.attempt + 1, }, }); if (response.status === 429) { task.attempt++; const rateLimitWindow = requestRateLimitWindow(response); const rateLimitHeader = requestFlowHeader( response.headers, REQUEST_FLOW_RESPONSE_HEADERS.serverLimit ); const knownWindow = rateLimitWindow.known || /(?:^|;)\s*w\s*=\s*"?(?:10|60)\b/i.test(rateLimitHeader); const endpointLimit = knownWindow ? null : noteUnknownEndpointRateLimit(task, response); if (!endpointLimit) { noteRateLimit(response, task.attempt, { rateLimitWindow, }); } else { const error = requestStatusError('HTTP 429(端点冷却)', 429); error.rateLimitScope = 'endpoint'; error.retryAt = endpointLimit.blockedUntil; task.controlReason = 'endpoint-cooldown'; throw error; } if (task.attempt <= POST_REQUEST_MAX_429_RETRIES && task.priority <= POST_REQUEST_PRIORITY.visible) { task.state = 'queued'; task.controller = null; task.queuedAt = requestFlowNow(); task.permittedAt = 0; task.waitReason = 'cooldown'; queue.push(task); return; } } if (!response.ok) { throw requestStatusError('HTTP ' + response.status, response.status); } const responseLatency = Math.max(1, Date.now() - startedAt); responseLatencyEwma = responseLatencyEwma ? responseLatencyEwma * 0.8 + responseLatency * 0.2 : responseLatency; responseLatencyPeak = Math.max(responseLatency, responseLatencyPeak * 0.9); responseLatencySamples++; endpointRateLimitCircuits.delete(task.endpointFingerprint); const payload = await response.json(); tasksByKey.delete(task.key); task.state = 'done'; task.resolve(payload); } catch (error) { tasksByKey.delete(task.key); task.state = 'done'; if (timedOut && !(externalSignal && externalSignal.aborted)) { task.reject(requestTimeoutError()); } else { task.reject(error); } } finally { clearTimeout(timeout); if (externalSignal) externalSignal.removeEventListener('abort', abort); activeControllers.delete(controller); task.controller = null; if (!task.keepSharedPermit && task.sharedPermitId && SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId); } task.sharedPermitId = ''; activeCount = Math.max(0, activeCount - 1); schedulePump(); } }; function pump() { if (pumpTimer) clearTimeout(pumpTimer); pumpTimer = 0; if (disposed || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return; sortQueue(); while (queue.length) { const task = queue.shift(); if (task.signal && task.signal.aborted) { task.controlReason = 'signal'; finishQueuedTask(task, abortError()); continue; } task.state = 'shared-permit'; sharedPermitPending = true; const permitController = new AbortController(); const abortPermit = () => permitController.abort(); pendingPermitControllers.add(permitController); if (task.signal) task.signal.addEventListener('abort', abortPermit, { once: true }); const acquire = SHARED_REQUEST_COORDINATOR && task.countsTowardApi ? SHARED_REQUEST_COORDINATOR.acquirePermit({ priority: task.priority, signal: permitController.signal, shortBudget: shortWindowBudget, longBudget: longWindowBudget, minInterval: requestMinInterval, maxConcurrent: effectiveMaxConcurrent(), type: task.sharedType || requestFlowType(requestFlowUrl(task.url), 'fetch', task.fetchOptions.method || 'GET'), }) : Promise.resolve({ startedAt: Date.now(), recoveryProbe: false }); acquire.then((permit) => { task.sharedPermitId = String(permit.permitId || ''); if (disposed || permitController.signal.aborted) throw abortError(); task.sharedRecoveryProbe = permit.recoveryProbe === true; const startedAt = Number(permit.startedAt) || Date.now(); task.permittedAt = startedAt; task.waitReason = String(permit.waitReason || task.waitReason); runTask(task); }).catch((error) => { if (error && error.name === 'AbortError' && error.message && error.message !== 'Aborted') { task.controlReason = error.message; } if (task.sharedPermitId && SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId); task.sharedPermitId = ''; } if (tasksByKey.get(task.key) === task) finishQueuedTask(task, error); }).finally(() => { if (task.signal) task.signal.removeEventListener('abort', abortPermit); pendingPermitControllers.delete(permitController); sharedPermitPending = false; schedulePump(); }); break; } schedulePump(); } const requestJSON = (url, fetchOptions = {}, requestOptions = {}) => { const key = String(requestOptions.key || url); const priority = Number.isFinite(requestOptions.priority) ? requestOptions.priority : POST_REQUEST_PRIORITY.visible; const existing = tasksByKey.get(key); if (existing) { if (priority < existing.priority && existing.state === 'queued') existing.priority = priority; schedulePump(); return existing.promise; } const callSite = String(requestOptions.callSite || requestFlowCallSite()); const method = String(fetchOptions.method || 'GET').toUpperCase(); const endpointFingerprint = endpointRateLimitIdentity(url, method); const endpointRoute = endpointRateLimitIdentity(url, method, true); const endpointLimit = endpointRateLimitState(endpointFingerprint); let controlError = null; let controlReason = ''; if (endpointLimit) { controlError = requestStatusError('端点 429 冷却中', 429); controlError.rateLimitScope = 'endpoint'; controlError.retryAt = endpointLimit.blockedUntil; controlReason = 'endpoint-cooldown'; } else if (disposed) { controlError = abortError('context-close'); controlReason = 'context-close'; } else if (queue.length >= READER_REQUEST_QUEUE_LIMIT && priority >= POST_REQUEST_PRIORITY.nested) { controlError = abortError('queue-limit'); controlReason = 'queue-limit'; } if (controlError) { recordRequestFlowControl(url, { method, priority, callSite, type: requestOptions.sharedType, waitReason: controlReason === 'endpoint-cooldown' ? controlReason : '', controlReason, }); return Promise.reject(controlError); } const { signal, ...options } = fetchOptions; let resolveTask; let rejectTask; const promise = new Promise((resolve, reject) => { resolveTask = resolve; rejectTask = reject; }); const task = { key, url, fetchOptions: options, signal, priority, sequence: sequence++, queuedAt: requestFlowNow(), permittedAt: 0, waitReason: activeCount >= effectiveMaxConcurrent() ? 'concurrency' : queue.length ? 'priority' : '', callSite, attempt: 0, state: 'queued', controller: null, sharedRecoveryProbe: false, sharedPermitId: '', keepSharedPermit: false, controlRecorded: false, controlReason: '', resolve: resolveTask, reject: rejectTask, promise, permitOnly: requestOptions.permitOnly === true, countsTowardApi: requestOptions.countsTowardApi !== false, sharedType: String(requestOptions.sharedType || ''), endpointFingerprint, endpointRoute, }; tasksByKey.set(key, task); queue.push(task); schedulePump(); return promise; }; const waitForPermit = (priority = POST_REQUEST_PRIORITY.visible, options = {}) => requestJSON('', { signal: options.signal, }, { key: `external-permit:${sequence}:${Date.now()}`, priority, permitOnly: true, countsTowardApi: options.countsTowardApi !== false, sharedType: options.type, }); const trackExternalController = (controller) => { if (!controller || typeof controller.abort !== 'function') return () => {}; if (disposed) { controller.abort(); return () => {}; } externalControllers.add(controller); return () => externalControllers.delete(controller); }; const cancelQueued = (key) => { const task = tasksByKey.get(String(key)); if (!task || task.state !== 'queued') return false; const index = queue.indexOf(task); if (index >= 0) queue.splice(index, 1); task.controlReason = 'cancelled'; finishQueuedTask(task, abortError('cancelled')); return true; }; const resetRateLimits = () => { endpointRateLimitCircuits.clear(); unknownRateLimitEvidence.length = 0; schedulePump(); return SHARED_REQUEST_COORDINATOR && SHARED_REQUEST_COORDINATOR.resetRateLimits ? SHARED_REQUEST_COORDINATOR.resetRateLimits() : Promise.resolve(true); }; const destroy = () => { if (disposed) return; disposed = true; if (pumpTimer) clearTimeout(pumpTimer); pumpTimer = 0; queue.splice(0).forEach((task) => { task.controlReason = 'context-close'; finishQueuedTask(task, abortError('context-close')); }); pendingPermitControllers.forEach((controller) => controller.abort()); pendingPermitControllers.clear(); activeControllers.forEach((controller) => controller.abort()); activeControllers.clear(); externalControllers.forEach((controller) => controller.abort()); externalControllers.clear(); endpointRateLimitCircuits.clear(); unknownRateLimitEvidence.length = 0; }; const snapshot = () => { const at = Date.now(); pruneEndpointRateLimitState(at); const endpointBlocks = Array.from(endpointRateLimitCircuits.values()) .filter((state) => state.blockedUntil > at); sortQueue(at); const shared = SHARED_REQUEST_COORDINATOR && SHARED_REQUEST_COORDINATOR.snapshot( shortWindowBudget, longWindowBudget, requestMinInterval, effectiveMaxConcurrent(), at ); return { active: activeCount, queued: queue.length + (sharedPermitPending ? 1 : 0), globalQueued: shared ? shared.queued : queue.length, globalActive: shared ? shared.active : activeCount, globalInstances: shared ? shared.instances : 1, globalMaxConcurrent: shared ? shared.maxConcurrent : effectiveMaxConcurrent(), globalMinInterval: shared ? shared.minInterval : requestMinInterval, coordinationMode: shared ? shared.coordinationMode : 'local', maxConcurrent: effectiveMaxConcurrent(), minInterval: requestMinInterval, configuredMinInterval: requestMinInterval, requestRateTarget: Math.round(requestRateTarget * 100), configuredShortWindowBudget: shortWindowBudget, configuredLongWindowBudget: longWindowBudget, shortWindowBudget: shared ? shared.shortBudget : shortWindowBudget, longWindowBudget: shared ? shared.longBudget : longWindowBudget, shortWindowCount: shared ? shared.shortCount : 0, longWindowCount: shared ? shared.longCount : 0, blockingReason: shared ? shared.blockingReason : '', nextPermitDelay: shared ? shared.nextPermitDelay : 0, coolingDown: !!(shared && shared.coolingDown), cooldownRemaining: shared ? shared.cooldownRemaining : 0, endpointCoolingDown: endpointBlocks.length > 0, endpointCooldownCount: endpointBlocks.length, endpointCooldownRemaining: endpointBlocks.reduce( (maximum, state) => Math.max(maximum, state.blockedUntil - at), 0 ), learnedRateLimit: !!shared && (shared.rateLimited || shared.shortBudget < shortWindowBudget || shared.longBudget < longWindowBudget), lastRateLimitWindow: shared ? shared.lastRateLimitWindow : '', }; }; return { requestJSON, waitForPermit, trackExternalController, cancelQueued, destroy, noteRateLimit, resetRateLimits, snapshot, }; } async function apiSend(url, method, params, extraHeaders, requestOptions = {}) { const priority = Number.isFinite(requestOptions.priority) ? requestOptions.priority : POST_REQUEST_PRIORITY.visible; const requestType = requestFlowType(requestFlowUrl(url), 'fetch', method); const requestScope = createRequestAbortScope(); const initialScheduler = requestOptions.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER; const untrackInitialController = initialScheduler && typeof initialScheduler.trackExternalController === 'function' ? initialScheduler.trackExternalController(requestScope.controller) : () => {}; let requestPermit = null; let sharedPermit = null; try { const opt = { method, credentials: 'include', headers: Object.assign({ 'Accept': 'application/json', 'X-CSRF-Token': await csrfToken(), 'X-Requested-With': 'XMLHttpRequest', }, extraHeaders || {}), }; if (requestScope.signal.aborted) throw new DOMException('Aborted', 'AbortError'); if (params) { if (requestOptions.jsonBody === true) { opt.headers['Content-Type'] = 'application/json; charset=UTF-8'; opt.body = JSON.stringify(params); } else { opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; const body = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (Array.isArray(value)) value.forEach((item) => body.append(`${key}[]`, item)); else if (value != null) body.append(key, value); }); opt.body = body.toString(); } } requestPermit = await waitForReaderRequestPermit(priority, { type: requestType, signal: requestScope.signal, controller: requestScope.controller, trackedScheduler: initialScheduler, surviveReaderSwitch: requestOptions.surviveReaderSwitch === true, }); if (!requestPermit.scheduler) { sharedPermit = await acquireSharedReaderRequestPermit( priority, requestScope.signal, requestType, ACTIVE_READER_REQUEST_SCHEDULER); } requestScope.startTimeout(); const res = await fetch(url, { ...opt, signal: requestScope.signal, [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, recoveryProbe: !!( requestPermit.recoveryProbe || (sharedPermit && sharedPermit.recoveryProbe) ), queuedAt: requestPermit.queuedAt, permittedAt: requestPermit.permittedAt || sharedPermit && sharedPermit.startedAt || requestFlowNow(), waitReason: requestPermit.waitReason || sharedPermit && sharedPermit.waitReason || '', callSite: requestPermit.callSite, attempt: 1, }, }); if (res.status === 429) { const rateScheduler = ACTIVE_READER_REQUEST_SCHEDULER || requestPermit.scheduler; if (rateScheduler) { rateScheduler.noteRateLimit(res); } else noteSharedRequestRateLimit(res); } let data = {}; try { data = await res.json(); } catch (error) { if (requestScope.signal.aborted) throw error; } requestScope.stopTimeout(); if (!res.ok) { const message = Array.isArray(data.errors) && data.errors.length ? data.errors.join(';') : (data.error || data.message || `HTTP ${res.status}`); const statusError = requestStatusError(message, res.status); statusError.cloudflareMitigated = res.status === 403 && requestFlowHeader(res.headers, ['cf-mitigated']).toLowerCase() === 'challenge'; throw statusError; } const mutationTags = Array.from(new Set([ ...responseCacheMutationTags(url, params, data), ...(Array.isArray(requestOptions.invalidateTags) ? requestOptions.invalidateTags : []), ])); if (mutationTags.length) await clearStoredResponseCache({ tags: mutationTags }); return data; } catch (error) { if (requestScope.timedOut()) throw requestTimeoutError(); throw error; } finally { if (requestPermit && typeof requestPermit.release === 'function') requestPermit.release(); if (sharedPermit && SHARED_REQUEST_COORDINATOR) { SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId); } untrackInitialController(); requestScope.cleanup(); } } function runReaderActionOnce(ctx, key, operation) { if (!ctx || !key) return Promise.resolve().then(operation); if (!ctx.readerActionRequests) ctx.readerActionRequests = new Map(); const normalizedKey = String(key); const existing = ctx.readerActionRequests.get(normalizedKey); if (existing) return existing; const request = Promise.resolve().then(operation).finally(() => { if (ctx.readerActionRequests && ctx.readerActionRequests.get(normalizedKey) === request) { ctx.readerActionRequests.delete(normalizedKey); } }); ctx.readerActionRequests.set(normalizedKey, request); return request; } function setReaderControlBusy(control, busy) { if (!control) return; if (busy) { if (control.dataset.ldpRequestBusy !== '1') { control.dataset.ldpRequestWasDisabled = control.disabled ? '1' : '0'; control.dataset.ldpRequestBusy = '1'; } control.disabled = true; return; } if (control.dataset.ldpRequestBusy !== '1') return; control.disabled = control.dataset.ldpRequestWasDisabled === '1'; delete control.dataset.ldpRequestBusy; delete control.dataset.ldpRequestWasDisabled; } function pollName(poll) { return String((poll && poll.name) || 'poll'); } function pollSavedVotes(postData, poll) { const votes = postData && postData.polls_votes && postData.polls_votes[pollName(poll)]; return Array.isArray(votes) ? votes.map(String) : []; } function pollIsClosed(poll, ctx) { if (!poll) return true; if (poll.status === 'closed' || (ctx && ctx.topicData && ctx.topicData.archived)) return true; if (!poll.close) return false; const closeAt = Date.parse(poll.close); return Number.isFinite(closeAt) && closeAt <= Date.now(); } function currentUserCanVoteInPoll(poll) { if (!ME_USERNAME) return false; const required = String((poll && poll.groups) || '').split(',').map((name) => name.trim().toLowerCase()).filter(Boolean); if (!required.length) return true; const groups = Array.isArray(ME_CURRENT_USER && ME_CURRENT_USER.groups) ? ME_CURRENT_USER.groups.map((group) => String((group && (group.name || group.full_name)) || '').toLowerCase()) : []; return required.some((name) => groups.includes(name)); } function pollCanShowResults(postData, poll, ctx) { if (!poll || !Array.isArray(poll.options) || !poll.options.some((option) => option && option.votes != null)) return false; const closed = pollIsClosed(poll, ctx); const resultRule = String(poll.results || 'always'); if (resultRule === 'on_close' && !closed) return false; if (resultRule === 'staff_only' && !(ME_CURRENT_USER && ME_CURRENT_USER.staff)) return false; if (resultRule === 'on_vote' && !pollSavedVotes(postData, poll).length) { const currentUserId = +(ME_CURRENT_USER && ME_CURRENT_USER.id || 0); if (!currentUserId || +postData.user_id !== currentUserId) return false; } return true; } function pollChoiceLimits(poll) { const optionCount = Array.isArray(poll && poll.options) ? poll.options.length : 0; const min = Math.max(1, Number.parseInt(poll && poll.min, 10) || 1); const parsedMax = Number.parseInt(poll && poll.max, 10); const max = Math.max(min, Math.min(optionCount, Number.isFinite(parsedMax) ? parsedMax : optionCount)); return { min, max }; } function pollTitleHtml(container, poll) { const existing = container.querySelector('.poll-title, .ldp-poll-title'); if (existing) return `<div class="ldp-poll-title">${existing.innerHTML}</div>`; return poll && poll.title ? `<div class="ldp-poll-title">${esc(poll.title)}</div>` : ''; } function pollOptionHtml(option) { return String((option && option.html) || ''); } function renderPollChoices(postData, poll, ctx) { const name = pollName(poll); const saved = new Set(pollSavedVotes(postData, poll)); const multiple = poll.type === 'multiple'; const closed = pollIsClosed(poll, ctx); const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice'; const inputType = multiple ? 'checkbox' : 'radio'; const options = (poll.options || []).map((option, index) => { const id = String(option.id || ''); return `<label class="ldp-poll-option"> <input type="${inputType}" name="ldp-poll-${escAttr(postData.id)}-${escAttr(name)}" value="${escAttr(id)}" data-poll-option="${escAttr(id)}"${saved.has(id) ? ' checked' : ''}${canVote ? '' : ' disabled'}> <span class="ldp-poll-option-text">${pollOptionHtml(option) || `选项 ${index + 1}`}</span> </label>`; }).join(''); return `<div class="ldp-poll-options">${options}</div>`; } function renderPollResults(poll) { const voters = Math.max(0, Number(poll.voters) || 0); const options = (poll.options || []).map((option) => { const votes = Math.max(0, Number(option.votes) || 0); const percent = voters > 0 ? Math.round(votes / voters * 100) : 0; return `<div class="ldp-poll-result"> <div class="ldp-poll-result-label">${pollOptionHtml(option)}</div> <div class="ldp-poll-result-value">${votes} 票 · ${percent}%</div> <div class="ldp-poll-result-track"><span class="ldp-poll-result-bar" style="width:${Math.min(100, percent)}%"></span></div> </div>`; }).join(''); return `<div class="ldp-poll-results">${options}</div>`; } function renderReaderPoll(container, postData, poll, ctx, showResultsOverride) { const name = pollName(poll); const title = pollTitleHtml(container, poll); const saved = pollSavedVotes(postData, poll); const closed = pollIsClosed(poll, ctx); const canShowResults = pollCanShowResults(postData, poll, ctx); const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice'; const previousMode = container.dataset.ldpPollShowResults; let showResults = showResultsOverride == null ? (previousMode === '1' || (previousMode === undefined && (saved.length > 0 || closed))) : !!showResultsOverride; if (!canShowResults) showResults = false; container.classList.add('ldp-reader-poll'); container.dataset.ldpPollName = name; container.dataset.ldpPollShowResults = showResults ? '1' : '0'; container.removeAttribute('aria-busy'); let note = ''; if (poll.type === 'ranked_choice') note = '排序投票请在原页面参与。'; else if (closed) note = '投票已结束。'; else if (!ME_USERNAME) note = '登录后可参与投票。'; else if (!currentUserCanVoteInPoll(poll)) note = '你不在该投票允许参与的用户组中。'; const { min, max } = pollChoiceLimits(poll); const multipleHint = poll.type === 'multiple' ? ` · 可选 ${min}${min === max ? '' : `–${max}`} 项` : ''; const footerButtons = []; if (!showResults && poll.type === 'multiple' && canVote) { const valid = saved.length >= min && saved.length <= max; footerButtons.push(`<button class="ldp-poll-button ldp-poll-button-primary" type="button" data-poll-action="submit"${valid ? '' : ' disabled'}>${saved.length ? '更新投票' : '提交投票'}</button>`); } if (!showResults && saved.length && canVote) { footerButtons.push('<button class="ldp-poll-button" type="button" data-poll-action="remove">撤销投票</button>'); } if (canShowResults && (!showResults || canVote)) { footerButtons.push(`<button class="ldp-poll-button" type="button" data-poll-action="toggle-results">${showResults && canVote ? '返回投票' : '结果'}</button>`); } const votersLabel = `${Math.max(0, Number(poll.voters) || 0)} 位投票人${multipleHint}`; container.innerHTML = `${title} ${showResults ? renderPollResults(poll) : renderPollChoices(postData, poll, ctx)} ${note ? `<div class="ldp-poll-note">${esc(note)}</div>` : ''} <div class="ldp-poll-footer"><span class="ldp-poll-meta">${votersLabel}</span>${footerButtons.join('')}</div>`; } function renderPostPolls(postNode, ctx, mode) { if (!postNode) return; const postData = postNode._ldpPostData || {}; const polls = Array.isArray(postData.polls) ? postData.polls : []; if (!polls.length) return; const content = postNode.querySelector(':scope > .ldp-content'); if (!content) return; const containers = Array.from(content.querySelectorAll('.poll')); const used = new Set(); polls.forEach((poll) => { const name = pollName(poll); let container = containers.find((item) => !used.has(item) && String(item.dataset.ldpPollName || item.dataset.pollName || 'poll') === name); if (!container) container = containers.find((item) => !used.has(item)) || null; if (!container) { container = document.createElement('div'); container.className = 'poll'; content.appendChild(container); } used.add(container); const showResults = mode && mode.name === name ? mode.showResults : null; renderReaderPoll(container, postData, poll, ctx, showResults); }); } function readerPostCopies(postId, ctx, includeDetachedNested = false) { const copies = new Set(); const key = String(postId); if (ctx && ctx.modal) { ctx.modal.querySelectorAll('.ldp-post').forEach((node) => { if (String(node.dataset.postId || '') === key) copies.add(node); }); } if (ctx && ctx.streamNodeMap) { ctx.streamNodeMap.forEach((node) => { if (!node) return; if (String(node.dataset.postId || '') === key) copies.add(node); if (!includeDetachedNested || !node.querySelectorAll) return; node.querySelectorAll('.ldp-post').forEach((nested) => { if (String(nested.dataset.postId || '') === key) copies.add(nested); }); }); } return Array.from(copies); } function persistReaderPostState(ctx, postData) { const postId = +(postData && postData.id || 0); if (!ctx || !ctx.topicId || !postId) return; const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId)); const topic = (entry && entry.topic) || ctx.topicData; const cachedPost = postsFromPayload(topic).find((post) => +post.id === postId); if (cachedPost) { Object.assign(cachedPost, postData); setCachedTopicData(ctx.topicId, topic); return; } if (ctx.loader && typeof ctx.loader.persistFetchedPosts === 'function') { ctx.loader.persistFetchedPosts([postData]); } } function syncPostVotingComments(postId, ctx, comments) { const normalized = Array.isArray(comments) ? comments : []; let latestPostData = null; readerPostCopies(postId, ctx).forEach((copy) => { const postData = copy._ldpPostData || {}; postData._ldpPvComments = normalized; postData.comments_count = Math.max(+postData.comments_count || 0, normalized.length); copy._ldpPostData = postData; latestPostData = postData; renderPostVoting(copy, ctx); }); if (latestPostData) persistReaderPostState(ctx, latestPostData); } function setReaderPostControlsBusy(postId, ctx, selector, busy, sourcePost = null) { new Set([sourcePost, ...readerPostCopies(postId, ctx)].filter(Boolean)).forEach((copy) => { copy.querySelectorAll(selector).forEach((control) => setReaderControlBusy(control, busy)); }); } function updatePostLikeAction(postData, acted, count) { if (!Array.isArray(postData.actions_summary)) postData.actions_summary = []; let action = postData.actions_summary.find((item) => +item.id === 2); if (!action) { action = { id: 2, can_act: true }; postData.actions_summary.push(action); } action.acted = !!acted; action.count = Math.max(0, +count || 0); return action; } function syncReaderLikeButton(button, action) { button.classList.toggle('liked', !!action.acted); button.dataset.acted = action.acted ? '1' : '0'; button.setAttribute('aria-label', action.acted ? '取消点赞' : '点赞'); const countEl = button.querySelector('.ldp-like-count'); if (countEl) countEl.textContent = String(action.count); } function syncReaderPostLike(postId, ctx, acted, count) { let latestPostData = null; readerPostCopies(postId, ctx).forEach((copy) => { const postData = copy._ldpPostData || {}; const action = updatePostLikeAction(postData, acted, count); copy._ldpPostData = postData; latestPostData = postData; copy.querySelectorAll('.ldp-like').forEach((button) => syncReaderLikeButton(button, action)); }); if (latestPostData) persistReaderPostState(ctx, latestPostData); } async function toggleReaderPostLike(postId, ctx, button, options) { const { setBusy, beforeSync, postData, persist, onError } = options; const countNode = button && button.querySelector('.ldp-like-count'); const acted = !!button && button.dataset.acted === '1'; const count = Math.max(0, +(countNode && countNode.textContent || 0)); setBusy(true); try { if (!acted) await apiSend(`${BASE}/post_actions`, 'POST', { id: postId, post_action_type_id: 2, flag_topic: false }); else await apiSend(`${BASE}/post_actions/${postId}?post_action_type_id=2`, 'DELETE'); const nextActed = !acted; const nextCount = Math.max(0, count + (acted ? -1 : 1)); if (beforeSync) beforeSync(nextActed, nextCount); syncReaderPostLike(postId, ctx, nextActed, nextCount); if (persist) persistReaderPostState(ctx, postData); if (typeof ctx.refreshBookmarksPanel === 'function') { ctx.refreshBookmarksPanel({ post: postData, topic: ctx.topicData, likeActed: nextActed }); } } catch (error) { onError(error); } finally { setBusy(false); } } function syncReaderPollResponse(sourcePost, ctx, name, serverPoll, votes, showResults) { let latestPostData = null; readerPostCopies(sourcePost.dataset.postId, ctx).forEach((node) => { const postData = node._ldpPostData || {}; const polls = Array.isArray(postData.polls) ? postData.polls : []; const current = polls.find((poll) => pollName(poll) === name); if (current && serverPoll) Object.assign(current, serverPoll); if (!postData.polls_votes) postData.polls_votes = {}; if (votes == null) delete postData.polls_votes[name]; else postData.polls_votes[name] = votes.map(String); node._ldpPostData = postData; latestPostData = postData; rememberPostData(ctx, postData); renderPostPolls(node, ctx, { name, showResults }); }); if (latestPostData) persistReaderPostState(ctx, latestPostData); } function setReaderPollBusy(post, ctx, name, busy) { const copies = readerPostCopies(post && post.dataset.postId, ctx); (copies.length ? copies : [post]).filter(Boolean).forEach((copy) => { copy.querySelectorAll('.ldp-reader-poll').forEach((container) => { if (container.dataset.ldpPollName !== name) return; if (busy) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy'); container.querySelectorAll('input,button').forEach((control) => setReaderControlBusy(control, busy)); }); }); } async function updateReaderPollVote(post, ctx, name, options = null) { const removing = options == null; return runReaderActionOnce(ctx, `post:${post.dataset.postId}:poll:${name}`, async () => { setReaderPollBusy(post, ctx, name, true); try { const params = { post_id: post.dataset.postId, poll_name: name, ...(removing ? {} : { options }), }; const data = await apiSend(`${BASE}/polls/vote`, removing ? 'DELETE' : 'PUT', params); syncReaderPollResponse(post, ctx, name, data.poll, removing ? null : options, !removing); } catch (error) { readerPostCopies(post.dataset.postId, ctx).forEach((copy) => renderPostPolls(copy, ctx)); alert(`${removing ? '撤销投票' : '投票'}失败:${error.message}`); } finally { setReaderPollBusy(post, ctx, name, false); } }); } const REACTION_EMOJI = { heart: '❤️', heart_eyes: '😍', laughing: '😂', open_mouth: '😮', cry: '😢', angry: '😠', '+1': '👍', thumbs_up: '👍', thumbs_down: '👎', tada: '🎉', clap: '👏', confetti_ball: '🎊', hugs: '🤗', thinking: '🤔', smile: '🙂', rocket: '🚀', fire: '🔥', eyes: '👀', }; let reactionEmojiUrls = null; let reactionEmojiImageSources = null; let reactionEmojiRequest = null; function loadReactionEmojiRegistry() { if (reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls); if (reactionEmojiRequest) return reactionEmojiRequest; reactionEmojiRequest = fetchJSON(`${BASE}/emojis.json`, { key: 'emoji-registry', priority: POST_REQUEST_PRIORITY.background, }).then((data) => { const registry = new Map(); Object.values(data || {}).forEach((items) => { if (!Array.isArray(items)) return; items.forEach((item) => { const name = String(item && item.name || ''); const url = String(item && item.url || ''); if (name && url) registry.set(name, url); }); }); reactionEmojiUrls = registry; reactionEmojiImageSources = new Set(Array.from(registry.values()) .map(comparableLightboxImageSource) .filter(Boolean)); return registry; }).catch((error) => { if (shouldStopReaderFallback(error)) return new Map(); reactionEmojiUrls = new Map(); reactionEmojiImageSources = new Set(); return reactionEmojiUrls; }).finally(() => { reactionEmojiRequest = null; }); return reactionEmojiRequest; } function reactionLabel(id) { const key = String(id || '').replace(/^:|:$/g, ''); const source = reactionEmojiUrls && reactionEmojiUrls.get(key); if (source) { const url = absoluteImageUrl(source); if (url) { return `<img class="emoji only-emoji" src="${escAttr(url)}" alt="${escAttr(key)}" loading="lazy" decoding="async">`; } } return REACTION_EMOJI[key] || ''; } function renderPostReactions(postNode, ctx) { if (!postNode) return; const post = postNode._ldpPostData || {}; const reactions = Array.isArray(post.reactions) ? post.reactions : []; const validSource = Array.isArray(ctx && ctx.topicData && ctx.topicData.valid_reactions) ? ctx.topicData.valid_reactions : reactions.map((item) => item.id); const valid = validSource.map((item) => String( typeof item === 'string' ? item : (item && (item.id || item.name)) || '' )).filter(Boolean); const renderedReactions = reactions.map((item) => ({ item, label: reactionLabel(item.id) })) .filter((entry) => entry.label); const renderedValid = valid.map((id) => ({ id, label: reactionLabel(id) })) .filter((entry) => entry.label); const canAdd = !!ME_USERNAME && renderedValid.length > 0; let root = postNode.querySelector(':scope > .ldp-reactions'); const actions = root?.querySelector(':scope > .ldp-actions') || postNode.querySelector('.ldp-actions'); if (!root) { root = document.createElement('div'); root.className = 'ldp-reactions'; const content = postNode.querySelector('.ldp-content'); content && content.after(root); } if (!renderedReactions.length && !canAdd) { root.querySelector(':scope > .ldp-reaction-summary')?.remove(); root.querySelector(':scope > .ldp-reaction-picker')?.remove(); if (actions && actions.parentElement !== root) root.appendChild(actions); postNode.classList.remove('ldp-has-reactions'); return; } postNode.classList.add('ldp-has-reactions'); const current = String(post.current_user_reaction && post.current_user_reaction.id || ''); const summaryHtml = renderedReactions.map(({ item, label }) => { const id = String(item.id || ''); return `<button class="ldp-reaction-chip${id === current ? ' on' : ''}" type="button" data-reaction="${escAttr(id)}" aria-label="${escAttr(id)}"><span>${label}</span><b>${Math.max(0, +item.count || 0)}</b></button>`; }).join('') + (canAdd ? `<span class="ldp-reaction-picker-anchor"><button class="ldp-reaction-add ldp-btn" type="button" data-reaction-picker aria-expanded="false" aria-label="添加回应">${icon('heart')}</button><div class="ldp-reaction-picker" hidden>${renderedValid.map(({ id, label }) => `<button class="ldp-reaction-chip${String(id) === current ? ' on' : ''}" type="button" data-reaction="${escAttr(id)}" aria-label="${escAttr(id)}">${label}</button>`).join('')}</div></span>` : ''); root.innerHTML = `<div class="ldp-reaction-summary">${summaryHtml}</div>`; if (actions) root.appendChild(actions); } function reactionStateSnapshot(post) { return { reactions: Array.isArray(post && post.reactions) ? post.reactions.map((item) => ({ ...item })) : [], current_user_reaction: post && post.current_user_reaction ? { ...post.current_user_reaction } : null, reaction_users_count: post && post.reaction_users_count, }; } function toggledPostReactionState(post, reaction) { const state = reactionStateSnapshot(post); const current = String(state.current_user_reaction && state.current_user_reaction.id || ''); const target = String(reaction || ''); const adjustCount = (id, delta) => { if (!id) return; const item = state.reactions.find((entry) => String(entry.id || '') === id); if (item) item.count = Math.max(0, Number(item.count) + delta || 0); else if (delta > 0) state.reactions.push({ id, type: 'emoji', count: delta }); }; if (current) adjustCount(current, -1); if (current !== target) adjustCount(target, 1); state.reactions = state.reactions.filter((item) => Number(item.count) > 0); state.current_user_reaction = current === target ? null : { id: target, type: 'emoji', can_undo: true }; if (Number.isFinite(Number(state.reaction_users_count))) { state.reaction_users_count = Math.max(0, Number(state.reaction_users_count) + (current ? (current === target ? -1 : 0) : 1)); } return state; } async function toggleReaderPostReaction(postNode, ctx, reaction, onSync, onError) { const postId = +(postNode && (postNode.dataset.postId || postNode._ldpPostData?.id) || 0); return runReaderActionOnce(ctx, `post:${postId}:reaction`, async () => { const previousState = reactionStateSnapshot(postNode._ldpPostData || {}); const sync = (fresh) => { syncSpecialPostData(postNode, ctx, fresh); onSync?.(fresh); }; setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add', true, postNode); sync(toggledPostReactionState(postNode._ldpPostData || {}, reaction)); try { const data = await apiSend(`${BASE}/discourse-reactions/posts/${postId}/custom-reactions/${encodeURIComponent(reaction)}/toggle.json`, 'PUT'); sync(data.post || data); ctx.refreshBookmarksPanel?.({ post: postNode._ldpPostData, topic: ctx.topicData }); } catch (error) { sync(previousState); onError(error); } finally { setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add', false, postNode); } }); } function postVotingCommentHtml(comment) { const user = comment.user || {}; const username = comment.username || user.username || ''; const avatar = userCardAvatarHtml( persistentAvatarHtml( user.avatar_template, 28, 'ldp-pv-comment-avatar', comment.name || user.name || username || '?' ), username ); return `<div class="ldp-pv-comment" data-comment-id="${escAttr(comment.id)}"> ${avatar || '<span></span>'} <div class="ldp-pv-comment-body"><div class="ldp-pv-comment-meta"><b>${esc(comment.name || user.name || username)}</b> @${esc(username)} · ${esc(fmtTime(comment.created_at))}</div><div class="cooked">${readerMediaHtml(comment.cooked || esc(comment.raw || ''))}</div></div> <button class="ldp-pv-comment-vote${comment.user_voted ? ' on' : ''}" type="button" data-pv-comment-vote aria-label="赞同评论">${icon('chevronUp')}<span>${Math.max(0, +comment.post_voting_vote_count || 0)}</span></button> </div>`; } function postVotingComments(postData) { if (Array.isArray(postData && postData._ldpPvComments)) return postData._ldpPvComments; return Array.isArray(postData && postData.comments) ? postData.comments : []; } function readerPostVotingComments(postId, ctx) { return readerPostCopies(postId, ctx) .map((copy) => postVotingComments(copy._ldpPostData)) .sort((left, right) => right.length - left.length)[0] || []; } function postVotingCapabilities(post, ctx) { const topicEnabled = !!(ctx && ctx.isPostVoting); const voteControls = topicEnabled || ['post_voting_vote_count', 'post_voting_user_voted_direction', 'post_voting_has_votes'] .some((key) => Object.hasOwn(post || {}, key)); const comments = topicEnabled || ['comments_count', 'comments'] .some((key) => Object.hasOwn(post || {}, key)); return { voteControls, comments }; } function renderPostVoting(postNode, ctx) { if (!postNode || !ctx) return; const post = postNode._ldpPostData || {}; const capabilities = postVotingCapabilities(post, ctx); if (!capabilities.voteControls && !capabilities.comments) { postNode.classList.remove('ldp-post-voting-answer'); postNode.querySelector(':scope > .ldp-pv-comments')?.remove(); return; } const isQuestion = +post.post_number === 1; postNode.classList.toggle('ldp-post-voting-answer', !isQuestion); postNode.classList.remove('ldp-reply', 'ldp-reply-collapsible', 'ldp-nested-collapsed'); postNode.querySelector(':scope > .ldp-nested-toggle')?.remove(); postNode.querySelector(':scope > .ldp-children')?.remove(); postNode.querySelector(':scope > .ldp-collapse-replies')?.remove(); postNode.querySelector(':scope > .ldp-sub-loading')?.remove(); if (isQuestion) { postNode.querySelector(':scope > .ldp-pv-comments')?.remove(); return; } const directContent = postNode.querySelector(':scope > .ldp-content'); const wrappedContent = postNode.querySelector(':scope > .ldp-pv-layout .ldp-content'); const content = directContent || wrappedContent; if (capabilities.voteControls && content && !content.parentElement.classList.contains('ldp-pv-main')) { const main = document.createElement('div'); main.className = 'ldp-pv-main'; content.before(main); main.appendChild(content); const layout = document.createElement('div'); layout.className = 'ldp-pv-layout'; const votes = document.createElement('div'); votes.className = 'ldp-pv-votes'; layout.append(votes, main); const head = postNode.querySelector(':scope > .ldp-post-head'); head && head.after(layout); } if (!capabilities.voteControls && wrappedContent) { const layout = wrappedContent.closest('.ldp-pv-layout'); layout.before(wrappedContent); layout.remove(); } const direction = String(post.post_voting_user_voted_direction || ''); const votes = postNode.querySelector('.ldp-pv-votes'); if (votes) votes.innerHTML = `<button class="ldp-pv-vote${direction === 'up' ? ' on' : ''}" type="button" data-pv-vote="up" aria-label="赞同">${icon('chevronUp')}</button><span class="ldp-pv-score">${+post.post_voting_vote_count || 0}</span><button class="ldp-pv-vote${direction === 'down' ? ' on' : ''}" type="button" data-pv-vote="down" aria-label="反对">${icon('chevronDown')}</button>`; let comments = postNode.querySelector(':scope > .ldp-pv-comments'); if (!capabilities.comments) { comments?.remove(); return; } const count = Math.max(0, +post.comments_count || (Array.isArray(post.comments) ? post.comments.length : 0)); if (!comments) { comments = document.createElement('div'); comments.className = 'ldp-pv-comments'; postNode.appendChild(comments); } const expanded = comments.dataset.expanded === '1'; const loaded = postVotingComments(post); comments.innerHTML = `<button class="ldp-pv-comments-toggle" type="button" aria-expanded="${expanded}" aria-label="${expanded ? '收起评论' : '展开评论'}">${icon('messageSquare')}<span>${count}</span></button><div class="ldp-pv-comments-body"${expanded ? '' : ' hidden'}>${loaded.map(postVotingCommentHtml).join('')}<button class="ldp-pv-comments-more" type="button"${loaded.length < count ? '' : ' hidden'}>加载更多评论</button>${ME_USERNAME ? '<form class="ldp-pv-comment-form"><input class="ldp-pv-comment-input" name="raw" autocomplete="off" placeholder="写评论…" required><button class="ldp-pv-comment-submit" type="submit">发送</button></form>' : ''}</div>`; comments.dataset.expanded = expanded ? '1' : '0'; hydratePersistentAvatarImages(comments); } function eventDateLabel(event) { const options = { year: 'numeric', month: 'short', day: 'numeric', hour: event.all_day ? undefined : '2-digit', minute: event.all_day ? undefined : '2-digit' }; const start = event.starts_at ? new Date(event.starts_at) : null; const end = event.ends_at ? new Date(event.ends_at) : null; const format = (date) => date && Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', options) : ''; return [format(start), format(end)].filter(Boolean).join(' — '); } function renderPostEvent(postNode) { if (!postNode) return; const post = postNode._ldpPostData || {}; const event = post.event; let card = postNode.querySelector(':scope > .ldp-event-card'); if (!event) { if (card) card.remove(); return; } if (!card) { card = document.createElement('section'); card.className = 'ldp-event-card'; postNode.querySelector(':scope > .ldp-content')?.after(card); } const watching = event.watching_invitee || {}; const stats = event.stats || {}; const closed = event.is_closed || event.is_expired || event.can_update_attendance === false; const location = event.location && (typeof event.location === 'string' ? event.location : (event.location.name || event.location.address || event.location.display || '')); card.innerHTML = `<h3 class="ldp-event-title">${esc(event.name || '活动')}</h3><div class="ldp-event-grid"><div><b>${esc(eventDateLabel(event))}</b>${event.timezone ? ` <span class="ldp-event-meta">${esc(event.timezone)}</span>` : ''}</div>${location ? `<div>地点:${esc(location)}</div>` : ''}${event.description_html ? `<div>${event.description_html}</div>` : ''}<div class="ldp-event-meta">参加 ${+stats.going || 0} · 感兴趣 ${+stats.interested || 0}${event.max_attendees ? ` · 名额 ${+stats.going || 0}/${event.max_attendees}` : ''}${event.is_ongoing ? ' · 进行中' : event.is_expired ? ' · 已结束' : ''}</div></div><div class="ldp-event-actions">${['going:参加','interested:感兴趣','not_going:不参加'].map((item) => { const [status,label] = item.split(':'); return `<button class="ldp-event-action${watching.status === status ? ' on' : ''}" type="button" data-event-status="${status}"${closed ? ' disabled' : ''}>${label}</button>`; }).join('')}<a class="ldp-event-ics" href="${BASE}/discourse-post-event/events.ics?post_id=${encodeURIComponent(post.id)}" download>下载日历</a></div>`; prepareReaderMedia(card); } const SYSTEM_ACTION_LABELS = { closed: '主题已关闭', opened: '主题已重新开放', archived: '主题已归档', unarchived: '主题已取消归档', pinned: '主题已置顶', unpinned: '主题已取消置顶', autoclosed: '主题已自动关闭', split_topic: '帖子已拆分到新主题', merged: '主题已合并', moved: '帖子已移动', visible: '主题已公开', invisible: '主题已隐藏', 'visible.enabled': '主题已公开', 'visible.disabled': '主题已取消公开', renamed: '主题标题已修改', assigned: '已指定', }; const SYSTEM_ACTION_ICONS = { visible: 'check', invisible: 'eyeOff', 'visible.enabled': 'check', 'visible.disabled': 'eyeOff', assigned: 'userPlus', }; function systemActionContentHtml(post, actionCode) { const actionLabel = SYSTEM_ACTION_LABELS[actionCode] || `系统操作:${actionCode.replace(/[._]/g, ' ')}`; if (actionCode !== 'assigned') return `<span>${esc(actionLabel)}</span>`; const username = cleanUsernameValue(post && post.action_code_who); if (!username) return `<span>${esc(actionLabel)}</span>`; return `<span>${esc(`${actionLabel}给 `)}<a class="ldp-user-link ldp-system-action-user" href="${escAttr(userProfileUrl(username))}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">@${esc(username)}</a></span>`; } function postNoticeType(post) { const notice = post && post.notice; return notice && typeof notice === 'object' ? String(notice.type || '') : ''; } function postNoticeIdentity(post, type = postNoticeType(post)) { if (type === 'new_user') return { label: '新用户', title: '新用户,首次发帖', icon: 'userPlus' }; if (type === 'returning_user') return { label: '回归用户', title: '回归用户,久未发帖', icon: 'rotateCcw' }; if (type !== 'custom') return null; const notice = post && post.notice; const customNotice = notice && typeof notice === 'object' ? `${String(notice.raw || '')} ${String(notice.cooked || '')}` : ''; return /\bpremium\b/i.test(customNotice) ? { label: 'Premium', title: 'Premium', icon: 'check' } : { label: '富可敌国', title: '富可敌国', icon: 'tag' }; } function ensureNestedReplyToggle(post, referenceNode) { if (post.querySelector(':scope > .ldp-nested-toggle')) return; const toggle = document.createElement('button'); toggle.className = 'ldp-btn ldp-nested-toggle'; toggle.type = 'button'; post.insertBefore(toggle, referenceNode); } function renderSpecialPostState(postNode) { if (!postNode) return; const post = postNode._ldpPostData || {}; const head = postNode.querySelector(':scope > .ldp-post-head'); let hiddenBadge = head && head.querySelector(':scope > .ldp-hidden-badge'); const hiddenCollapsible = !!post.hidden && !postNode.classList.contains('ldp-nested-preview'); const wasHiddenCollapsible = postNode.classList.contains('ldp-hidden-collapsible'); if (post.hidden) { if (!hiddenBadge && head) { hiddenBadge = document.createElement('span'); hiddenBadge.className = 'ldp-special-badge ldp-hidden-badge warn'; hiddenBadge.textContent = '已隐藏'; const floor = head.querySelector(':scope > .ldp-floor'); head.insertBefore(hiddenBadge, floor || null); } } else if (hiddenBadge) hiddenBadge.remove(); if (hiddenCollapsible) { postNode.classList.add('ldp-hidden-collapsible'); ensureNestedReplyToggle(postNode, head || postNode.firstChild); if (!wasHiddenCollapsible) setNestedReplyCollapsed(postNode, true); } else if (wasHiddenCollapsible) { setNestedReplyCollapsed(postNode, false); postNode.classList.remove('ldp-hidden-collapsible'); if (!postNode.classList.contains('ldp-reply-collapsible')) { postNode.querySelector(':scope > .ldp-nested-toggle')?.remove(); } } let badges = postNode.querySelector(':scope > .ldp-special-badges'); const items = []; if (+post.post_type === 4) items.push(['私信回复', '']); if (+post.post_type === 2) items.push(['管理操作', 'warn']); if (post.wiki) items.push(['Wiki', '']); if (post.deleted_at) items.push(['已删除', 'danger']); if (post.locked) items.push(['已锁定', 'warn']); if (items.length) { if (!badges) { badges = document.createElement('div'); badges.className = 'ldp-special-badges'; head && head.after(badges); } badges.innerHTML = items.map(([label, type]) => `<span class="ldp-special-badge ${type}">${label}</span>`).join(''); } else if (badges) badges.remove(); postNode.classList.toggle('ldp-whisper', +post.post_type === 4); postNode.classList.toggle('ldp-system-post', +post.post_type === 2 || +post.post_type === 3); let notice = postNode.querySelector(':scope > .ldp-special-notice'); const noticeType = postNoticeType(post); if (post.notice && !['new_user', 'returning_user', 'custom'].includes(noticeType)) { if (!notice) { notice = document.createElement('div'); notice.className = 'ldp-special-notice'; head && head.after(notice); } notice.innerHTML = typeof post.notice === 'string' ? post.notice : esc(post.notice.type || '站点提示'); } else if (notice) notice.remove(); let action = postNode.querySelector(':scope > .ldp-system-action'); const actionCode = String(post.action_code || ''); const needsAction = (+post.post_type === 2 || +post.post_type === 3) && actionCode; postNode.classList.toggle('ldp-system-action-compact', needsAction && actionCode === 'assigned'); if (needsAction) { if (!action) { action = document.createElement('div'); action.className = 'ldp-system-action'; head && head.after(action); } action.innerHTML = `${icon(SYSTEM_ACTION_ICONS[actionCode] || 'settings')}${systemActionContentHtml(post, actionCode)}`; } else if (action) action.remove(); } function acceptedTopicAnswers(ctx) { const topic = ctx && ctx.topicData; if (!topic) return []; const answersByPostNumber = new Map(); const addAnswer = (answer) => { if (!answer) return; const candidate = typeof answer === 'object' ? answer : { post_number: answer }; const postNumber = +(candidate.post_number || 0); if (postNumber <= 1) return; answersByPostNumber.set(postNumber, Object.assign( {}, answersByPostNumber.get(postNumber) || {}, candidate, { post_number: postNumber } )); }; if (Array.isArray(topic.accepted_answers)) topic.accepted_answers.forEach(addAnswer); addAnswer(topic.accepted_answer); postsFromPayload(topic).forEach((post) => { if (post && post.accepted_answer) addAnswer(post); }); if (ctx.streamNodeMap) { ctx.streamNodeMap.forEach((node) => { const post = node && node._ldpPostData; if (post && post.accepted_answer) addAnswer(post); }); } return Array.from(answersByPostNumber.values()).sort((a, b) => a.post_number - b.post_number); } function renderSolvedAnswerCard(postNode, ctx) { if (!postNode) return; const post = postNode._ldpPostData || {}; const answers = acceptedTopicAnswers(ctx); let card = postNode.querySelector(':scope > .ldp-solved-card'); const isStarter = +post.post_number === 1 && !postNode.classList.contains('ldp-nested-preview'); if (!isStarter || !answers.length) { if (card) card.remove(); return; } const topicPosts = postsFromPayload(ctx.topicData); if (!card) { card = document.createElement('section'); card.className = 'ldp-solved-card'; const content = postNode.querySelector(':scope > .ldp-content'); if (!content) return; content.after(card); } card.innerHTML = `<div class="ldp-solved-head">${icon('check')}<span>${answers.length > 1 ? `已解决 · ${answers.length} 个答案` : '已解决'}</span></div>${answers.map((answer) => { const postNumber = +answer.post_number; const acceptedNode = ctx.streamNodeMap && ctx.streamNodeMap.get(postNumber); const acceptedPost = acceptedNode && acceptedNode._ldpPostData || topicPosts.find((item) => +item.post_number === postNumber) || {}; const username = String(answer.username || acceptedPost.username || ''); const name = String(answer.name || acceptedPost.name || username || '已解决回复'); const profileUrl = username ? userProfileUrl(username) : ''; const avatar = persistentAvatarHtml( answer.avatar_template || acceptedPost.avatar_template, 32, 'ldp-solved-avatar', name ); const time = fmtTime(answer.created_at || acceptedPost.created_at); const answerHtml = String(answer.cooked || acceptedPost.cooked || answer.excerpt || acceptedPost.excerpt || '').trim(); return `<div class="ldp-solved-body" data-solved-post-number="${postNumber}"> <div class="ldp-solved-author-row"> ${avatar ? `<a class="ldp-user-link" href="${escAttr(profileUrl)}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">${avatar}</a>` : ''} ${profileUrl ? `<a class="ldp-user-link ldp-solved-author" href="${escAttr(profileUrl)}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">${esc(name)}</a>` : `<span class="ldp-solved-author">${esc(name)}</span>`} ${username ? `<span class="ldp-solved-username">@${esc(username)}</span>` : ''} ${time ? `<span>· ${esc(time)}</span>` : ''} <button class="ldp-solved-floor ldp-jump-self" type="button" data-target-post-number="${postNumber}" aria-label="跳到楼层 #${postNumber}">#${postNumber}</button> </div> ${answerHtml ? `<div class="ldp-solved-excerpt ldp-content cooked">${readerMediaHtml(answerHtml, { inlineOneboxes: true })}</div>` : '<div class="ldp-solved-excerpt">查看被采纳的完整回复。</div>'} <button class="ldp-solved-jump ldp-jump-self" type="button" data-target-post-number="${postNumber}">阅读更多</button> </div>`; }).join('')}`; } function prepareReaderImageCarousels(root) { root.querySelectorAll('.d-image-grid[data-mode="carousel"]').forEach((grid) => { if (grid.dataset.ldpCarouselPrepared === '1') return; const items = Array.from(grid.querySelectorAll('.lightbox-wrapper')) .filter((item) => item.closest('.d-image-grid') === grid); if (items.length < 2) return; grid.dataset.ldpCarouselPrepared = '1'; grid.classList.add('ldp-media-carousel'); const track = document.createElement('div'); track.className = 'ldp-media-carousel-track'; track.tabIndex = 0; track.setAttribute('role', 'region'); track.setAttribute('aria-label', `多图轮播,共 ${items.length} 张`); items.forEach((item) => track.append(item)); const controls = document.createElement('div'); controls.className = 'ldp-media-carousel-controls'; const previous = document.createElement('button'); previous.type = 'button'; previous.setAttribute('aria-label', '上一张图片'); previous.innerHTML = icon('chevronRight'); const status = document.createElement('span'); status.className = 'ldp-media-carousel-status'; status.setAttribute('aria-live', 'polite'); const next = document.createElement('button'); next.type = 'button'; next.setAttribute('aria-label', '下一张图片'); next.innerHTML = icon('chevronRight'); controls.append(previous, status, next); grid.replaceChildren(track, controls); let activeIndex = 0; let syncFrame = 0; const sync = () => { syncFrame = 0; activeIndex = items.reduce((closest, item, index) => ( Math.abs(item.offsetLeft - track.scrollLeft) < Math.abs(items[closest].offsetLeft - track.scrollLeft) ? index : closest ), 0); previous.disabled = activeIndex === 0; next.disabled = activeIndex === items.length - 1; status.textContent = `${activeIndex + 1} / ${items.length}`; }; const scheduleSync = () => { if (!syncFrame) syncFrame = requestAnimationFrame(sync); }; const show = (index) => { const target = items[Math.max(0, Math.min(items.length - 1, index))]; if (!target) return; const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; track.scrollTo({ left: target.offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' }); }; previous.addEventListener('click', () => show(activeIndex - 1)); next.addEventListener('click', () => show(activeIndex + 1)); track.addEventListener('scroll', scheduleSync, { passive: true }); sync(); }); } function prepareReaderFrame(frame) { frame.loading = 'lazy'; try { const url = new URL(frame.getAttribute('src') || '', BASE); if (url.hostname !== 'player.bilibili.com') return; url.searchParams.set('autoplay', '0'); frame.src = url.href; frame.classList.add('ldp-bilibili-player'); frame.setAttribute('allow', 'fullscreen; picture-in-picture'); frame.setAttribute('allowfullscreen', ''); } catch (error) {} } function readerHlsSource(video) { const candidates = [video, ...video.querySelectorAll('source')]; for (const candidate of candidates) { const source = String(candidate.getAttribute('src') || '').trim(); if (!source) continue; const type = String(candidate.getAttribute('type') || '').toLowerCase(); let isHls = /(?:vnd\.apple\.mpegurl|x-mpegurl)/.test(type); if (!isHls) { try { isHls = /\.m3u8$/i.test(new URL(source, BASE).pathname); } catch (error) {} } if (isHls) return new URL(source, BASE).href; } return ''; } function bindReaderHls(video) { if (!video || video.dataset.ldpHlsBound === '1') return; const source = readerHlsSource(video); if (!source) return; const nativeHls = !!video.canPlayType('application/vnd.apple.mpegurl') && 'ManagedMediaSource' in window; if (nativeHls) return; const HlsPlayer = window.Hls; if (!HlsPlayer || typeof HlsPlayer.isSupported !== 'function' || !HlsPlayer.isSupported()) return; const player = new HlsPlayer(); video.dataset.ldpHlsBound = '1'; video._ldpHlsPlayer = player; player.loadSource(source); player.attachMedia(video); } function destroyReaderMedia(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('video').forEach((video) => { const player = video._ldpHlsPlayer; if (player && typeof player.destroy === 'function') player.destroy(); video._ldpHlsPlayer = null; delete video.dataset.ldpHlsBound; }); } function suspendReaderMedia(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('video,audio').forEach((media) => { try { media.pause(); } catch (error) {} }); destroyReaderMedia(root); } function activateReaderMedia(root) { if (!root || typeof root.querySelectorAll !== 'function' || document.visibilityState !== 'visible') return; root.querySelectorAll('video').forEach(bindReaderHls); } function prepareReaderMedia(root, options = {}) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('[title]').forEach((element) => element.removeAttribute('title')); if (options.interactive !== false) prepareReaderImageCarousels(root); root.querySelectorAll('iframe').forEach(prepareReaderFrame); root.querySelectorAll('video,audio').forEach((media) => { media.removeAttribute('autoplay'); media.autoplay = false; if (!media.hasAttribute('preload')) media.preload = 'metadata'; if (media.tagName === 'VIDEO') media.playsInline = true; }); root.querySelectorAll('img').forEach((img) => { if (img.classList.contains('emoji')) return; img.loading = 'lazy'; img.decoding = 'async'; }); } function prepareReaderCodeBlocks(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('pre').forEach((pre) => { if (pre.closest('.ldp-code-block')) return; const block = document.createElement('div'); block.className = 'ldp-code-block'; const actions = document.createElement('div'); actions.className = 'ldp-code-block-actions'; actions.innerHTML = ` <button class="ldp-code-block-action" type="button" data-reader-code-action="copy" aria-label="复制文本">${icon('copy')}</button> <button class="ldp-code-block-action" type="button" data-reader-code-action="preview" aria-label="在阅读器内预览文本">${icon('maximize2')}</button>`; pre.before(block); block.append(pre, actions); }); } async function copyReaderCode(source, button) { if (!source || !button || button.disabled) return; button.disabled = true; try { const text = 'value' in source ? source.value : source.textContent; await writeSelectionQuoteToClipboard(text || ''); button.innerHTML = icon('check'); button.setAttribute('aria-label', '已复制'); showSelectionToast('文本已复制'); setTimeout(() => { if (!button.isConnected) return; button.innerHTML = icon('copy'); button.setAttribute('aria-label', '复制文本'); button.disabled = false; }, 1200); } catch (error) { button.disabled = false; showSelectionToast('复制失败,请重试'); } } function readerCodeDownloadExtension(pre) { const code = pre && pre.querySelector('code'); const classNames = `${pre && pre.className || ''} ${code && code.className || ''}`; const language = (classNames.match(/(?:language|lang)-([a-z0-9_+-]+)/i) || [])[1]?.toLowerCase() || ''; return ({ javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts', python: 'py', py: 'py', bash: 'sh', shell: 'sh', sh: 'sh', html: 'html', css: 'css', json: 'json', sql: 'sql', rust: 'rs', go: 'go', java: 'java', c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php', ruby: 'rb', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md', })[language] || 'txt'; } function downloadReaderCodeCopy(pre, text) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const extension = readerCodeDownloadExtension(pre); const blob = new window.Blob([String(text || '')], { type: 'text/plain;charset=utf-8' }); triggerBrowserDownload(blob, `linuxdo-code-copy-${timestamp}.${extension}`); showSelectionToast('编辑副本已下载到本地'); } function openReaderCodePreview(pre, modal) { if (!pre || !modal) return; const overlay = modal.closest('.ldp-overlay'); if (overlay && typeof overlay._ldpCloseCodePreview === 'function') overlay._ldpCloseCodePreview(false, true); const previousFocus = readerActiveElement(); const layer = document.createElement('section'); layer.className = 'ldp-code-preview-layer'; layer.setAttribute('role', 'dialog'); layer.setAttribute('aria-modal', 'true'); layer.setAttribute('aria-label', '文本预览'); const sourceStyle = getComputedStyle(pre); layer.style.setProperty('--ldp-code-preview-font-family', sourceStyle.fontFamily); layer.style.setProperty('--ldp-code-preview-font-size', sourceStyle.fontSize); layer.style.setProperty('--ldp-code-preview-font-weight', sourceStyle.fontWeight); layer.style.setProperty('--ldp-code-preview-line-height', sourceStyle.lineHeight); layer.style.setProperty('--ldp-code-preview-tab-size', sourceStyle.tabSize || '4'); layer.innerHTML = ` <header class="ldp-code-preview-head"> <strong>文本预览</strong> <span class="ldp-code-preview-actions"> <button class="ldp-code-block-action" type="button" data-reader-code-preview-action="edit" aria-label="编辑文本副本">${icon('pencil')}</button> <button class="ldp-code-block-action" type="button" data-reader-code-preview-action="save" aria-label="保存编辑副本到本地" hidden>${icon('download')}</button> <button class="ldp-code-block-action" type="button" data-reader-code-preview-action="copy" aria-label="复制文本">${icon('copy')}</button> <button class="ldp-code-block-action" type="button" data-reader-code-preview-action="close" aria-label="关闭文本预览">${icon('x')}</button> </span> </header> <div class="ldp-code-preview-body"></div>`; const previewPre = pre.cloneNode(true); const previewBody = layer.querySelector('.ldp-code-preview-body'); const previewTitle = layer.querySelector('.ldp-code-preview-head strong'); const editButton = layer.querySelector('[data-reader-code-preview-action="edit"]'); const saveButton = layer.querySelector('[data-reader-code-preview-action="save"]'); const closeButton = layer.querySelector('[data-reader-code-preview-action="close"]'); previewBody.append(previewPre); let activeTextSource = previewPre; let editing = false; let closed = false; const restorePreview = () => { activeTextSource = previewPre; editing = false; previewBody.replaceChildren(previewPre); previewTitle.textContent = '文本预览'; layer.setAttribute('aria-label', '文本预览'); editButton.hidden = false; saveButton.hidden = true; closeButton.setAttribute('aria-label', '关闭文本预览'); closeButton.focus(); }; const close = (restoreFocus = true, force = false) => { if (closed) return; if (editing && !force) { restorePreview(); return; } closed = true; document.removeEventListener('keydown', onKeyDown, true); layer.remove(); if (overlay && overlay._ldpCloseCodePreview === close) overlay._ldpCloseCodePreview = null; if (restoreFocus && previousFocus && previousFocus.isConnected) previousFocus.focus(); }; const onKeyDown = (event) => { if (event.key !== 'Escape') return; event.preventDefault(); event.stopImmediatePropagation(); close(); }; layer.addEventListener('click', (event) => { const action = event.target.closest('[data-reader-code-preview-action]'); if (!action) return; event.preventDefault(); event.stopPropagation(); if (action.dataset.readerCodePreviewAction === 'copy') { void copyReaderCode(activeTextSource, action); } else if (action.dataset.readerCodePreviewAction === 'save') { downloadReaderCodeCopy(pre, activeTextSource.value); } else if (action.dataset.readerCodePreviewAction === 'edit') { const editor = document.createElement('textarea'); editor.className = 'ldp-code-preview-editor'; editor.value = previewPre.textContent || ''; editor.spellcheck = false; editor.setAttribute('aria-label', '文本编辑副本'); editor.addEventListener('keydown', (keyEvent) => { if (keyEvent.key !== 'Tab') return; keyEvent.preventDefault(); editor.setRangeText('\t', editor.selectionStart, editor.selectionEnd, 'end'); }); activeTextSource = editor; editing = true; previewBody.replaceChildren(editor); previewTitle.textContent = '文本编辑(副本)'; layer.setAttribute('aria-label', '文本编辑副本'); saveButton.hidden = false; editButton.hidden = true; closeButton.setAttribute('aria-label', '返回文本预览'); editor.focus(); } else { close(); } }); modal.append(layer); bindFloatingSurfaceWheel(layer); if (overlay) overlay._ldpCloseCodePreview = close; document.addEventListener('keydown', onKeyDown, true); layer.querySelector('[data-reader-code-preview-action="close"]')?.focus(); } function prepareReaderHashtags(root) { if (!root || typeof root.querySelectorAll !== 'function') return; const iconTemplate = document.createElement('template'); iconTemplate.innerHTML = icon('tag', 'ldp-hashtag-icon'); root.querySelectorAll('.hashtag-cooked').forEach((hashtag) => { const iconHost = hashtag.matches('a') ? hashtag : hashtag.querySelector('a') || hashtag; if (iconHost.querySelector('img.emoji')) return; const existingSvg = iconHost.querySelector('svg'); if (existingSvg && existingSvg.querySelector('path,circle,rect,ellipse,line,polyline,polygon')) return; const tagIcon = iconTemplate.content.firstElementChild.cloneNode(true); tagIcon.setAttribute('focusable', 'false'); const placeholder = iconHost.querySelector('.hashtag-icon-placeholder'); if (placeholder) placeholder.replaceWith(tagIcon); else if (existingSvg) existingSvg.replaceWith(tagIcon); else iconHost.prepend(tagIcon); }); } function prepareReaderUserMentions(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('a.mention').forEach((link) => { let username = cleanUsernameValue(link.dataset.username); if (!username) { try { const url = new URL(link.getAttribute('href') || '', BASE); const match = url.origin === BASE && url.pathname.match(/^\/u\/([^/]+)\/?$/i); username = match ? decodeURIComponent(match[1]) : ''; } catch (error) {} } if (!username) username = cleanUsernameValue(link.textContent); if (!username) return; link.classList.add('ldp-user-link'); link.dataset.userCard = username; }); } const READER_CALLOUT_ICONS = Object.freeze(Object.fromEntries([ ['list', ['abstract', 'example', 'summary', 'tldr']], ['alertTriangle', ['attention', 'bug', 'caution', 'danger', 'error', 'warning']], ['check', ['check', 'done', 'success', 'todo']], ['messageSquare', ['cite', 'quote']], ['circleX', ['fail', 'failure', 'missing']], ['info', ['faq', 'help', 'info', 'question']], ['lightbulb', ['hint', 'important', 'tip']], ['pencil', ['note']], ].flatMap(([iconName, types]) => types.map((type) => [type, iconName])))); function prepareReaderCallouts(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('blockquote').forEach((quote) => { const firstBlock = quote.firstElementChild; if (!firstBlock) return; const walker = document.createTreeWalker(firstBlock, NodeFilter.SHOW_TEXT); let markerNode = null; while (walker.nextNode()) { if (String(walker.currentNode.nodeValue || '').trim()) { markerNode = walker.currentNode; break; } } if (!markerNode) return; const match = String(markerNode.nodeValue || '').match(/^\s*\[!([a-z][a-z0-9_-]*)\]([+-])?\s*/i); if (!match) return; const type = match[1].toLowerCase(); if (!READER_CALLOUT_ICONS[type]) return; markerNode.nodeValue = String(markerNode.nodeValue || '').slice(match[0].length); quote.classList.add('ldp-callout', `ldp-callout--${type}`); if (match[2]) { const body = document.createElement('div'); body.className = 'ldp-callout-body'; const firstBreak = firstBlock.querySelector('br'); if (firstBreak) { const range = document.createRange(); range.setStartAfter(firstBreak); range.setEnd(firstBlock, firstBlock.childNodes.length); body.append(range.extractContents()); firstBreak.remove(); } let sibling = firstBlock.nextSibling; while (sibling) { const next = sibling.nextSibling; body.append(sibling); sibling = next; } if (String(body.textContent || '').trim() || body.querySelector('*')) { quote.classList.add('ldp-callout--foldable'); quote.append(body); const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'ldp-callout-toggle'; toggle.innerHTML = icon('chevronDown'); const setExpanded = (expanded) => { body.hidden = !expanded; quote.classList.toggle('ldp-callout--collapsed', !expanded); toggle.setAttribute('aria-expanded', String(expanded)); toggle.setAttribute('aria-label', expanded ? '收起提示内容' : '展开提示内容'); }; toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true')); quote.append(toggle); setExpanded(match[2] === '+'); } } const marker = document.createElement('span'); marker.className = 'ldp-callout-icon'; marker.innerHTML = icon(READER_CALLOUT_ICONS[type]); quote.prepend(marker); }); } function prepareReaderPostQuotes(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('aside.quote').forEach((quote) => { const title = quote.querySelector(':scope > .title'); const body = quote.querySelector(':scope > blockquote'); if (!title || !body) return; quote.classList.add('ldp-post-quote'); quote.dataset.ldpQuoteExpanded = '0'; quote._ldpQuoteExcerptHtml = body.innerHTML; title.classList.add('ldp-quote-title'); const username = cleanUsernameValue(quote.dataset.username); const avatar = Array.from(title.querySelectorAll('img')).find((image) => !image.classList.contains('emoji')); if (username && avatar) { avatar.classList.add('ldp-user-avatar-card'); avatar.dataset.userCard = username; } const targetPostNumber = Math.max(0, +(quote.dataset.post || 0)); const targetTopicId = Math.max(0, +(quote.dataset.topic || 0)); let controls = title.querySelector(':scope > .quote-controls'); if (!controls) { controls = document.createElement('span'); controls.className = 'quote-controls'; title.append(controls); } controls.classList.add('ldp-quote-controls'); controls.replaceChildren(); const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'ldp-quote-toggle'; toggle.setAttribute('aria-label', '展开完整引用'); toggle.setAttribute('aria-expanded', 'false'); toggle.innerHTML = icon('chevronDown'); controls.append(toggle); if (targetPostNumber) { const jump = document.createElement('button'); jump.type = 'button'; jump.className = 'ldp-quote-jump'; jump.dataset.targetPostNumber = String(targetPostNumber); if (targetTopicId) jump.dataset.targetTopicId = String(targetTopicId); jump.setAttribute('aria-label', `跳到被引用楼层 #${targetPostNumber}`); jump.innerHTML = icon('arrowUp'); controls.append(jump); } }); } function cachedReaderQuotedPost(ctx, topicId, postNumber) { const targetTopicId = +topicId || +(ctx && ctx.topicId || 0); const targetPostNumber = +postNumber || 0; if (!targetTopicId || !targetPostNumber) return null; if (ctx && targetTopicId === +ctx.topicId) { const streamPost = ctx.streamNodeMap && ctx.streamNodeMap.get(targetPostNumber); const streamData = streamPost && streamPost._ldpPostData; if (streamData && streamData.cooked != null) return streamData; const topicPost = postsFromPayload(ctx.topicData).find((post) => +post.post_number === targetPostNumber); if (topicPost && topicPost.cooked != null) return topicPost; } return postsFromPayload(getCachedTopicData(targetTopicId)) .find((post) => +post.post_number === targetPostNumber && post.cooked != null) || null; } async function loadReaderQuotedPost(quote, ctx) { if (!quote || !ctx) return null; if (quote._ldpQuoteFullPost && quote._ldpQuoteFullPost.cooked != null) return quote._ldpQuoteFullPost; const targetPostNumber = +quote.dataset.post || 0; const targetTopicId = +quote.dataset.topic || +ctx.topicId || 0; let post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber); if (!post && targetTopicId === +ctx.topicId && ctx.loadPost) { await ctx.loadPost(targetPostNumber, { mount: false, priority: POST_REQUEST_PRIORITY.auxiliary, }); post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber); } if (!post) { const urls = [ `${BASE}/posts/by_number/${targetTopicId}/${targetPostNumber}.json`, `${BASE}/t/${targetTopicId}.json?post_number=${targetPostNumber}`, ]; for (const url of urls) { try { const payload = await fetchJSON(url, { scheduler: ctx.postRequestScheduler, key: `quote:${targetTopicId}:${targetPostNumber}:${url}`, priority: POST_REQUEST_PRIORITY.auxiliary, }); const posts = payload && payload.post_number ? [payload] : postsFromPayload(payload); post = posts.find((item) => +item.post_number === targetPostNumber && item.cooked != null) || null; if (post) break; } catch (error) { if (shouldStopReaderFallback(error)) throw error; } } } if (post) quote._ldpQuoteFullPost = post; return post; } function preserveReaderQuotePosition(quote, ctx, update) { const scrollRoot = ctx && ctx.scrollRoot; if (!quote || !quote.isConnected || !scrollRoot) { update(); return; } const anchorTop = quote.getBoundingClientRect().top; scrollRoot.classList.add('ldp-quote-positioning'); try { update(); const correction = quote.getBoundingClientRect().top - anchorTop; if (Math.abs(correction) >= 0.5) { const previousScrollTop = scrollRoot.scrollTop; scrollRoot.scrollTop += correction; markStreamInternalScroll(ctx, previousScrollTop); } } finally { requestAnimationFrame(() => scrollRoot.classList.remove('ldp-quote-positioning')); } } function syncReaderQuoteLayout(quote, ctx) { const post = quote && quote.closest('.ldp-post'); if (!post || !ctx) return; bindPostMediaLayoutSync(post, ctx); scheduleNestedBranchSync(post, ctx); const owner = streamOwnerPostNode(ctx, post); const item = owner && ctx.streamItemMap.get(streamPostNumber(owner)); if (!owner || !item) return; const sync = () => { if (!owner.isConnected) return; const measured = owner.getBoundingClientRect().height || owner.offsetHeight || 0; const height = Math.max(Math.round(measured), STREAM_PLACEHOLDER_MIN_HEIGHT); if (Math.abs(height - item.height) >= 1) { item.height = height; const index = ctx.onlyOp && Number.isInteger(item.layoutIndex) ? item.layoutIndex : item.index; markStreamPrefixDirty(ctx, index); ctx.streamNeedsRepair = true; } scheduleStreamWindowSync(ctx); }; requestAnimationFrame(() => { sync(); requestAnimationFrame(sync); }); } function readerQuoteExcerptText(quote) { if (!quote) return ''; const body = quote.querySelector(':scope > blockquote'); if (body && quote.dataset.ldpQuoteExpanded !== '1') return String(body.textContent || '').trim(); const html = quote._ldpQuoteExcerptHtml != null ? quote._ldpQuoteExcerptHtml : body && body.innerHTML; const template = document.createElement('template'); template.innerHTML = String(html || ''); return String(template.content.textContent || '').trim(); } function readerQuoteSourceFromJump(jump, ctx) { const preview = jump && jump.closest('.ldp-floor-preview'); const sourcePost = jump && (jump.closest('.ldp-post') || (preview && preview._ldpRelatedPost)); const postNumber = +(sourcePost && sourcePost.dataset.postNumber || 0); const topicId = +(ctx && ctx.topicId || 0); if (!sourcePost || !postNumber || !topicId) return null; const nested = !!(sourcePost.parentElement && sourcePost.parentElement.classList.contains('ldp-children')); return { topicId, postNumber, parentPostNumber: nested ? +(sourcePost.dataset.replyToPostNumber || 0) : 0, nested, postData: sourcePost._ldpPostData || null, }; } function quoteHighlightFromJump(jump, ctx) { const quote = jump && jump.closest('.ldp-post-quote'); const text = readerQuoteExcerptText(quote); return text ? { text, source: readerQuoteSourceFromJump(jump, ctx) } : null; } function discourseComparableQuoteText(value) { return String(value || '').replace(/[ \t\r\n]/g, ''); } function markReaderTextMatch(root, comparableText, excludedSelector, className) { const characters = []; const positions = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let textNode = walker.nextNode(); while (textNode) { const parent = textNode.parentElement; if (parent && !parent.closest(excludedSelector)) { const value = textNode.nodeValue || ''; for (let offset = 0; offset < value.length; offset++) { const character = value[offset]; if (/[ \t\r\n]/.test(character)) continue; characters.push(character); positions.push({ node: textNode, offset }); } } textNode = walker.nextNode(); } const matchStart = characters.join('').indexOf(comparableText); if (matchStart < 0) return []; const matchEnd = matchStart + comparableText.length; const groups = []; for (let index = matchStart; index < matchEnd; index++) { const position = positions[index]; if (!position) continue; const previous = groups[groups.length - 1]; if (previous && previous.node === position.node) previous.end = position.offset + 1; else groups.push({ node: position.node, start: position.offset, end: position.offset + 1 }); } const marks = []; for (let index = groups.length - 1; index >= 0; index--) { const group = groups[index]; const range = document.createRange(); range.setStart(group.node, group.start); range.setEnd(group.node, group.end); const mark = document.createElement('mark'); mark.className = className; range.surroundContents(mark); marks.unshift(mark); } return marks; } function highlightReaderExpandedQuote(quote) { const body = quote && quote.querySelector(':scope > blockquote'); const comparableQuote = discourseComparableQuoteText(readerQuoteExcerptText(quote)); if (!body || !comparableQuote) return false; return markReaderTextMatch(body, comparableQuote, 'button,script,style,noscript,.ldp-quote-title', 'ldp-expanded-quote-match').length > 0; } function clearReaderQuoteHighlight(ctx) { const state = ctx && ctx.quoteHighlightState; if (!state) return; if (state.hintHideTimer) clearTimeout(state.hintHideTimer); if (state.hint) state.hint.remove(); (state.marks || []).forEach((mark) => { if (!mark || !mark.parentNode) return; const parent = mark.parentNode; mark.replaceWith(document.createTextNode(mark.textContent || '')); parent.normalize(); }); ctx.quoteHighlightState = null; } function setReaderQuoteHighlightActive(ctx, active) { const state = ctx && ctx.quoteHighlightState; if (!state) return; state.active = active !== false; const hintText = state.active ? '点击关闭高亮' : '点击继续高亮'; state.marks.forEach((mark) => mark.classList.toggle('ldp-quote-match-muted', !state.active)); if (state.lastMark) { state.lastMark.setAttribute('aria-pressed', String(state.active)); state.lastMark.setAttribute('aria-label', hintText); } if (state.toggleControl) state.toggleControl.textContent = state.active ? '关闭高亮' : '继续高亮'; } function applyReaderQuoteHighlight(postNumber, ctx, quoteText, options = {}) { clearReaderQuoteHighlight(ctx); const comparableQuote = discourseComparableQuoteText(quoteText); const target = getStreamPostNode(ctx, postNumber, { mount: true }) || ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`); if (!target || !comparableQuote) return false; hydratePostContent(target, ctx); const content = target.querySelector(':scope > .ldp-content'); if (!content) return false; const marks = markReaderTextMatch(content, comparableQuote, 'button,script,style,noscript,.ldp-quote-highlight-close', 'ldp-quote-match'); if (!marks.length) { showSelectionToast('引用原文可能已修改,未找到匹配内容'); return false; } const firstMark = marks[0]; const lastMark = marks[marks.length - 1]; lastMark.classList.add('ldp-quote-match-end'); lastMark.tabIndex = 0; lastMark.setAttribute('role', 'button'); lastMark.setAttribute('data-tooltip', ''); const hint = document.createElement('span'); hint.className = 'ldp-quote-highlight-hint ldp-action-surface'; const toggleControl = document.createElement('button'); toggleControl.type = 'button'; toggleControl.className = 'ldp-quote-highlight-toggle'; hint.append(toggleControl); const source = options.source || null; let returnControl = null; if (source) { returnControl = document.createElement('button'); returnControl.type = 'button'; returnControl.className = 'ldp-quote-highlight-return'; returnControl.textContent = source.nested ? '返回楼中楼' : '返回引用楼层'; hint.append(returnControl); } const hintRoot = READER_PORTAL_CONTEXT_BODY || ensureReaderPortal(); (hintRoot || document.body).append(hint); ctx.quoteHighlightState = { marks, lastMark, hint, toggleControl, active: options.active !== false, postNumber: +postNumber, quoteText: String(quoteText || ''), source, hintHideTimer: 0, }; const toggleHighlight = (event) => { event.preventDefault(); event.stopPropagation(); cancelHintHide(); const state = ctx.quoteHighlightState; if (state) setReaderQuoteHighlightActive(ctx, !state.active); }; const cancelHintHide = () => { const state = ctx.quoteHighlightState; if (!state || !state.hintHideTimer) return; clearTimeout(state.hintHideTimer); state.hintHideTimer = 0; }; const showHint = (mark, event) => { const state = ctx.quoteHighlightState; if (!state || !mark) return; cancelHintHide(); state.hint.classList.add('ldp-quote-hint-visible'); const markRect = mark.getBoundingClientRect(); const rootRect = ctx.scrollRoot.getBoundingClientRect(); const hintRect = state.hint.getBoundingClientRect(); const anchorX = Number.isFinite(event && event.clientX) ? event.clientX : markRect.left + markRect.width / 2; const anchorY = Number.isFinite(event && event.clientY) ? event.clientY : markRect.top; const edge = 8; const gap = 10; const left = Math.max(edge, Math.min(anchorX - hintRect.width / 2, window.innerWidth - hintRect.width - edge)); const showBelow = anchorY - hintRect.height - gap < rootRect.top + edge; const top = showBelow ? anchorY + gap : anchorY - hintRect.height - gap; state.hint.style.left = `${Math.round(left)}px`; state.hint.style.top = `${Math.round(Math.max(edge, top))}px`; }; const scheduleHintHide = () => { const state = ctx.quoteHighlightState; if (!state) return; cancelHintHide(); state.hintHideTimer = setTimeout(() => { state.hintHideTimer = 0; if (state.hint.matches(':hover,:focus-within') || state.marks.some((mark) => mark.matches(':hover'))) return; state.hint.classList.remove('ldp-quote-hint-visible'); }, 160); }; marks.forEach((mark) => { mark.addEventListener('click', toggleHighlight); mark.addEventListener('mouseenter', (event) => showHint(mark, event)); mark.addEventListener('mouseleave', scheduleHintHide); }); hint.addEventListener('mouseenter', cancelHintHide); hint.addEventListener('pointerdown', cancelHintHide); hint.addEventListener('mouseleave', scheduleHintHide); hint.addEventListener('focusout', scheduleHintHide); toggleControl.addEventListener('click', toggleHighlight); if (returnControl) { returnControl.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); cancelHintHide(); if (returnControl.disabled) return; hint.classList.remove('ldp-quote-hint-visible'); returnControl.blur(); returnControl.disabled = true; const originalText = returnControl.textContent; returnControl.textContent = '返回中…'; const returned = await returnToReaderQuoteSource(source, ctx); if (!returned) showSelectionToast('暂时无法返回引用位置'); if (returnControl.isConnected) { returnControl.disabled = false; returnControl.textContent = originalText; } }); } lastMark.addEventListener('keydown', (event) => { if (event.target === lastMark && (event.key === 'Enter' || event.key === ' ')) toggleHighlight(event); }); lastMark.addEventListener('focus', (event) => showHint(lastMark, event)); lastMark.addEventListener('blur', scheduleHintHide); setReaderQuoteHighlightActive(ctx, options.active !== false); return firstMark; } function restoreReaderQuoteHighlightForPost(post, ctx) { const state = ctx && ctx.quoteHighlightState; const postNumber = +(post && post.dataset.postNumber); if (!state || !postNumber || +state.postNumber !== postNumber || !state.quoteText) return; if (state.marks && state.marks.length && state.marks.every((mark) => post.contains(mark))) return; applyReaderQuoteHighlight(postNumber, ctx, state.quoteText, { active: state.active, source: state.source }); } function readerMediaHtml(value, options = {}) { const template = document.createElement('template'); template.innerHTML = String(value || ''); prepareReaderHashtags(template.content); if (options.inlineOneboxes) prepareReaderInlineOneboxes(template.content); prepareReaderCodeBlocks(template.content); prepareReaderMedia(template.content, { interactive: false }); return template.innerHTML; } function normalizedPostLinkUrl(value) { try { return new URL(String(value || '').trim(), BASE).href; } catch (error) { return ''; } } function prepareReaderInlineOneboxes(root) { if (!root || typeof root.querySelectorAll !== 'function') return; root.querySelectorAll('a.inline-onebox').forEach((link) => { if (link.querySelector(':scope > .ldp-inline-onebox-label')) return; const labelNodes = Array.from(link.childNodes).filter((node) => !(node.nodeType === Node.ELEMENT_NODE && node.matches('svg,.svg-icon,.ldp-link-click-count'))); if (!labelNodes.some((node) => String(node.textContent || '').trim())) return; const label = document.createElement('span'); label.className = 'ldp-inline-onebox-label'; labelNodes.forEach((node) => label.append(node)); const linkIcon = Array.from(link.children).find((child) => child.matches('svg,.svg-icon')); if (linkIcon) linkIcon.after(label); else link.prepend(label); }); } function decoratePostLinkClickCounts(root, linkCounts) { if (!root || !Array.isArray(linkCounts) || !linkCounts.length) return; const countsByUrl = new Map(); linkCounts.forEach((item) => { const clicks = Math.max(0, Math.trunc(Number(item && item.clicks) || 0)); const url = !item || item.reflection ? '' : normalizedPostLinkUrl(item.url); if (!url || !clicks) return; countsByUrl.set(url, Math.max(clicks, countsByUrl.get(url) || 0)); }); if (!countsByUrl.size) return; root.querySelectorAll('a[href]').forEach((link) => { if (link.querySelector(':scope > .ldp-link-click-count')) return; const onebox = link.closest('aside.onebox'); if (onebox && link.closest('header.source')) { const titleLink = onebox.querySelector('.onebox-body h3 a[href]'); if (titleLink && normalizedPostLinkUrl(titleLink.getAttribute('href')) === normalizedPostLinkUrl(link.getAttribute('href'))) return; } const clicks = countsByUrl.get(normalizedPostLinkUrl(link.getAttribute('href'))); if (!clicks || !String(link.textContent || '').trim()) return; const count = document.createElement('span'); const label = `${clicks.toLocaleString('zh-CN')} 次点击`; count.className = 'ldp-link-click-count'; count.setAttribute('role', 'note'); count.setAttribute('aria-label', label); count.dataset.ldpTooltipLabel = label; count.textContent = clicks.toLocaleString('zh-CN'); link.append(count); }); } function setPostCookedContent(content, cooked, linkCounts) { const template = document.createElement('template'); template.innerHTML = String(cooked || ''); prepareReaderHashtags(template.content); prepareReaderUserMentions(template.content); prepareReaderInlineOneboxes(template.content); prepareReaderCallouts(template.content); prepareReaderPostQuotes(template.content); prepareReaderCodeBlocks(template.content); prepareReaderMedia(template.content); decoratePostLinkClickCounts(template.content, linkCounts); destroyReaderMedia(content); content.replaceChildren(template.content); activateReaderMedia(content); } function renderNewUserBadge(postNode) { if (!postNode) return; const post = postNode._ldpPostData || {}; const noticeType = postNoticeType(post); const isSystem = +post.post_type === 3 || String(post.username || '').trim().toLocaleLowerCase() === 'system'; const isModerator = post.moderator === true || post.group_moderator === true; const identity = isSystem ? { label: '系统', title: '系统账户', icon: 'settings' } : isModerator ? { label: '版主', title: '版主', icon: 'shieldHalved' } : postNoticeIdentity(post, noticeType); const isNewUser = noticeType === 'new_user'; postNode.classList.toggle('ldp-new-user', isNewUser); const head = postNode.querySelector(':scope > .ldp-post-head'); let badge = head && head.querySelector(':scope > .ldp-new-user-badge'); if (!identity) { if (badge) badge.remove(); return; } if (!badge && head) { badge = document.createElement('span'); badge.className = 'ldp-new-user-badge'; const username = head.querySelector(':scope > .ldp-user'); if (username) username.after(badge); else head.append(badge); } if (badge) { badge.setAttribute('role', 'img'); badge.setAttribute('aria-label', identity.title); badge.innerHTML = `${icon(identity.icon)}<span>${identity.label}</span>`; } } function renderPostSpecialContent(postNode, ctx) { renderNewUserBadge(postNode); renderSpecialPostState(postNode); renderSolvedAnswerCard(postNode, ctx); renderPostReactions(postNode, ctx); renderPostEvent(postNode); renderPostVoting(postNode, ctx); const post = postNode && postNode._ldpPostData || {}; if (post.accepted_answer && ctx && ctx.streamNodeMap) { const firstPost = ctx.streamNodeMap.get(1); if (firstPost && firstPost !== postNode) renderSolvedAnswerCard(firstPost, ctx); } hydratePersistentAvatarImages(postNode); } function comparePostVotingStreamItems(left, right) { if (left.postNumber === 1 || right.postNumber === 1) { return left.postNumber === right.postNumber ? 0 : (left.postNumber === 1 ? -1 : 1); } return +(right.node._ldpPostData.post_voting_vote_count || 0) - +(left.node._ldpPostData.post_voting_vote_count || 0) || left.postNumber - right.postNumber; } function syncSpecialPostData(postNode, ctx, fresh) { if (!postNode || !fresh) return; let latestPostData = null; readerPostCopies(postNode.dataset.postId, ctx).forEach((copy) => { const previous = copy._ldpPostData || {}; copy._ldpPostData = Object.assign({}, previous, fresh); latestPostData = copy._ldpPostData; renderPostSpecialContent(copy, ctx); }); if (latestPostData) persistReaderPostState(ctx, latestPostData); if (ctx && ctx.isPostVoting) { ctx.streamItems.sort(comparePostVotingStreamItems); reindexStreamItems(ctx, 0); invalidateStreamLayout(ctx); scheduleStreamWindowSync(ctx); } } async function fetchPostVotingComments(postNode, ctx) { const postId = postNode && postNode.dataset.postId; if (!postId) return []; return runReaderActionOnce(ctx, `post:${postId}:pv-comments-load`, async () => { const copies = readerPostCopies(postId, ctx); const sourcePosts = (copies.length ? copies : [postNode]).map((copy) => copy._ldpPostData || {}); const loaded = sourcePosts.map(postVotingComments).sort((a, b) => b.length - a.length)[0] || []; const lastId = loaded.length ? loaded[loaded.length - 1].id : ''; const url = new URL(`${BASE}/post_voting/comments`); url.searchParams.set('post_id', postId); if (lastId) url.searchParams.set('last_comment_id', lastId); const data = await fetchJSON(url.toString()); const incoming = Array.isArray(data) ? data : (data.comments || []); const merged = new Map(loaded.map((comment) => [String(comment.id), comment])); incoming.forEach((comment) => merged.set(String(comment.id), comment)); const comments = Array.from(merged.values()); syncPostVotingComments(postId, ctx, comments); return comments; }); } function normalizeBoosts(boosts) { const list = Array.isArray(boosts) ? boosts : boosts ? [boosts] : []; return list.map((boost) => { if (!boost) return null; const user = boost.user || {}; const raw = boost.raw || ''; const cooked = boost.cooked || (raw ? `<p>${esc(raw)}</p>` : ''); if (!cooked) return null; const username = user.username || boost.username || ME_USERNAME || ''; return { id: boost.id == null ? '' : String(boost.id), userId: +(user.id || boost.user_id || 0), username, name: user.name || boost.name || username, avatarTemplate: user.avatar_template || boost.avatar_template || boost.avatarTemplate || boost.avatar || '', admin: user.admin === true || boost.admin === true, moderator: user.moderator === true || user.group_moderator === true || boost.moderator === true || boost.group_moderator === true, noticeType: String((boost.notice && boost.notice.type) || boost.notice_type || ''), cooked, }; }).filter(Boolean); } function boostBelongsToCurrentUser(boost) { const currentUserId = +(ME_CURRENT_USER && ME_CURRENT_USER.id || 0); if (currentUserId && +boost.userId) return currentUserId === +boost.userId; return !!( ME_USERNAME && boost.username && String(boost.username).toLowerCase() === String(ME_USERNAME).toLowerCase() ); } function boostIdentitySourcePost(boost, ctx) { const username = String(boost && boost.username || '').toLocaleLowerCase(); if (!username || !ctx) return null; const cachedPosts = ctx.loader && Array.isArray(ctx.loader.cachedPosts) ? ctx.loader.cachedPosts : []; const candidates = cachedPosts.concat(postsFromPayload(ctx.topicData)); return candidates.find((post) => { if (String(post && post.username || '').toLocaleLowerCase() !== username) return false; return post.admin === true || post.moderator === true || post.group_moderator === true || ['new_user', 'returning_user', 'custom'].includes(postNoticeType(post)); }) || null; } function boostIdentities(boost, ctx) { const identities = []; const username = String(boost && boost.username || ''); const usernameKey = username.toLocaleLowerCase(); const sourcePost = boostIdentitySourcePost(boost, ctx) || {}; const noticeType = postNoticeType(sourcePost) || boost.noticeType; const isAdmin = boost.admin === true || sourcePost.admin === true; const isModerator = boost.moderator === true || sourcePost.moderator === true || sourcePost.group_moderator === true; if (boostBelongsToCurrentUser(boost)) { identities.push({ type: 'me', label: 'ME', title: '当前用户', icon: 'userRound' }); } if (usernameKey && String(ctx && ctx.op || '').toLocaleLowerCase() === usernameKey) { identities.push({ type: 'op', label: 'OP', title: '楼主', icon: 'award' }); } if (isAdmin) { identities.push({ type: 'admin', label: '管理员', title: '管理员', icon: 'shieldHalved' }); } else if (isModerator) { identities.push({ type: 'moderator', label: '版主', title: '版主', icon: 'shieldHalved' }); } const noticeIdentity = postNoticeIdentity(sourcePost, noticeType); if (noticeIdentity) { const type = noticeType === 'new_user' ? 'new' : noticeType === 'returning_user' ? 'return' : 'custom'; identities.push(Object.assign({ type }, noticeIdentity)); } return identities; } function renderBoostIdentities(boost, ctx) { const identities = boostIdentities(boost, ctx); if (!identities.length) return ''; return `<span class="ldp-boost-identities">${identities.map((identity) => `<span class="ldp-boost-identity ldp-boost-identity-${identity.type}" role="img" aria-label="${escAttr(identity.title)}">${icon(identity.icon)}<span>${esc(identity.label)}</span></span>` ).join('')}</span>`; } function renderBoostBubble(boost, allowQuickBoost = false, ctx = null) { const userCardAttr = boost.username ? `data-user-card="${escAttr(boost.username)}"` : ''; const avatarLabel = boost.username ? ` aria-label="@${escAttr(boost.username)}"` : ''; const avatar = boost.avatarTemplate ? persistentAvatarHtml(boost.avatarTemplate, 24, 'ldp-boost-avatar', boost.name || boost.username || '?') : icon('rocket', 'ldp-boost-fallback-icon'); const avatarHtml = boost.username ? `<a class="ldp-user-link ldp-boost-avatar-link" href="${userProfileUrl(boost.username)}" target="_blank" rel="noopener" ${userCardAttr}${avatarLabel} data-tooltip>${avatar}</a>` : avatar; const quickBoost = allowQuickBoost && boost.username && (!ME_USERNAME || boost.username !== ME_USERNAME) ? '<button class="ldp-boost-copy-action ldp-action-surface" type="button" aria-label="复制到 Boost 输入框" data-tooltip>复制</button>' : ''; const isOwnBoost = boostBelongsToCurrentUser(boost); const signedIn = !!(ME_CURRENT_USER && ME_CURRENT_USER.id || ME_USERNAME); const itemAction = signedIn && boost.id ? (isOwnBoost ? `<button class="ldp-boost-item-action ldp-boost-delete-action" type="button" aria-label="删除自己的 Boost">${icon('trash')}</button>` : `<button class="ldp-boost-item-action ldp-boost-report-action" type="button" aria-label="举报 @${escAttr(boost.username)} 的 Boost">${icon('flag')}</button>`) : ''; const identities = renderBoostIdentities(boost, ctx); return `<span class="ldp-boost-bubble${isOwnBoost ? ' ldp-boost-own' : ''}" data-boost-id="${escAttr(boost.id)}" data-boost-user="${escAttr(boost.username)}" data-boost-user-id="${escAttr(boost.userId)}" data-boost-admin="${boost.admin ? '1' : '0'}" data-boost-moderator="${boost.moderator ? '1' : '0'}" aria-label="${boost.username ? `@${escAttr(boost.username)} 的 Boost` : 'Boost'}">${avatarHtml}${identities}<span class="ldp-boost-cooked cooked">${readerMediaHtml(boost.cooked)}</span>${quickBoost}${itemAction}</span>`; } function renderBoostList(boosts, options = {}) { const items = normalizeBoosts(boosts); const allowQuickBoost = options.canQuickBoost === true && !items.some((boost) => ME_USERNAME && boost.username === ME_USERNAME); return `<div class="ldp-boost-list"${items.length ? '' : ' hidden'}>${items.map((boost) => renderBoostBubble(boost, allowQuickBoost, options.ctx)).join('')}</div>`; } function syncKnownBoostIdentities(ctx, usernames) { if (!ctx || !ctx.modal) return; const usernameKeys = new Set((Array.isArray(usernames) ? usernames : [usernames]) .map((username) => String(username || '').toLocaleLowerCase()).filter(Boolean)); if (!usernameKeys.size) return; ctx.modal.querySelectorAll('.ldp-boost-bubble').forEach((bubble) => { const username = String(bubble.dataset.boostUser || ''); if (!usernameKeys.has(username.toLocaleLowerCase())) return; const boost = { userId: +(bubble.dataset.boostUserId || 0), username, admin: bubble.dataset.boostAdmin === '1', moderator: bubble.dataset.boostModerator === '1', }; const holder = document.createElement('template'); holder.innerHTML = renderBoostIdentities(boost, ctx); const next = holder.content.firstElementChild; const current = bubble.querySelector(':scope > .ldp-boost-identities'); if (current && next) current.replaceWith(next); else if (current) current.remove(); else if (next) { const avatar = bubble.querySelector(':scope > .ldp-boost-avatar-link,:scope > .ldp-boost-fallback-icon'); if (avatar) avatar.after(next); else bubble.prepend(next); } }); } function boostBubblePlainText(bubble) { const cooked = bubble && bubble.querySelector('.ldp-boost-cooked'); if (!cooked) return ''; const copy = cooked.cloneNode(true); copy.querySelectorAll('img[alt]').forEach((image) => image.replaceWith(document.createTextNode(image.alt || ''))); return String(copy.textContent || '').replace(/\s+/g, ' ').trim(); } function boostCopyRegExpEscape(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function fitBoostCopyParts(prefix, base, suffix) { const suffixChars = Array.from(suffix).slice(0, BOOST_COPY_MAX_LENGTH); const prefixChars = Array.from(prefix).slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length); const baseChars = Array.from(base).slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length - prefixChars.length); return `${prefixChars.join('')}${baseChars.join('')}${suffixChars.join('')}`; } function applyBoostCopyRule(raw, prefs = PREFS) { const config = boostCopyConfigFromPrefs(prefs); let base = String(raw || '').replace(/\s+/g, ' ').trim(); if (config.prefix && base.startsWith(config.prefix)) base = base.slice(config.prefix.length); let suffix = config.fixedSuffix; if (config.mode === 'counter') { const countPattern = new RegExp(`${boostCopyRegExpEscape(config.counterMarker)}(\\d+)$`); const countMatch = base.match(countPattern); const currentCount = countMatch ? Number(countMatch[1]) : 0; const nextCount = Number.isSafeInteger(currentCount) ? currentCount + config.counterStep : config.counterStep; if (countMatch) base = base.slice(0, -countMatch[0].length); suffix = `${config.counterMarker}${nextCount}`; } else if (suffix && base.endsWith(suffix)) { base = base.slice(0, -suffix.length); } return fitBoostCopyParts(config.prefix, base, suffix); } function ensureMe(force = false) { if (!force && ME_USERNAME !== null) return Promise.resolve(ME_USERNAME); if (ME_REQUEST) return ME_REQUEST; ME_REQUEST = (async () => { try { const s = await fetchJSON(`${BASE}/session/current.json`, { key: 'session:current', priority: POST_REQUEST_PRIORITY.background, cacheMode: 'bypass', }); ME_CURRENT_USER = s.current_user || null; ME_USERNAME = (ME_CURRENT_USER && ME_CURRENT_USER.username) || ''; const unreadCount = currentUserUnreadNotificationCount(ME_CURRENT_USER); syncNotificationUnreadUi(unreadCount == null ? cachedUnreadNotificationCount() : unreadCount); } catch (error) { const transient = error && (error.name === 'AbortError' || error.status === 408 || error.status === 429 || error.status >= 500); if (transient) return ''; ME_CURRENT_USER = null; ME_USERNAME = ''; syncNotificationUnreadUi(cachedUnreadNotificationCount()); } return ME_USERNAME || ''; })().finally(() => { ME_REQUEST = null; }); return ME_REQUEST; } function likeInfo(p) { const like = (p.actions_summary || []).find((a) => a.id === 2) || {}; return { count: like.count || 0, acted: !!like.acted, canAct: !!like.can_act }; } function canShowBoostButton(p) { return !!(p && p.can_boost === true && (!ME_USERNAME || p.username !== ME_USERNAME)); } const TOPIC_NOTIFICATION_LEVELS = [ { value: 1, label: '常规' }, { value: 2, label: '跟踪' }, { value: 3, label: '关注' }, { value: 0, label: '已屏蔽' }, ]; function topicNotificationLevel(topic) { const value = Number(topic && ( topic.notification_level ?? (topic.details && topic.details.notification_level) )); return Number.isInteger(value) && value >= 0 && value <= 3 ? value : 1; } function topicActionCapabilities(post, ctx) { const data = post || {}; const topic = (ctx && ctx.topicData) || {}; const actions = Array.isArray(data.actions_summary) ? data.actions_summary : []; const hasFlagAction = actions.some((action) => action && action.can_act === true && ![2, 8].includes(+action.id) ); const signedIn = !!ME_USERNAME; const isOwnPost = signedIn && data.username === ME_USERNAME; const canReport = signedIn && !isOwnPost && data.can_flag !== false && (data.can_flag === true || hasFlagAction || actions.length <= 1); const canAssign = !!( data.can_assign === true || topic.can_assign === true || (topic.details && topic.details.can_assign === true) ); const currentUser = ME_CURRENT_USER || {}; const details = topic.details || {}; const canAdmin = signedIn && !!( currentUser.staff || currentUser.can_manage_topic || currentUser.canManageTopic || currentUser.can_change_post_owner || currentUser.canChangePostOwner || data.can_manage === true || data.can_wiki === true || details.can_edit_staff_notes === true ); return { canReport, canAssign, canBookmark: signedIn, canEdit: data.can_edit === true, canDelete: data.can_delete === true, canAdmin, }; } function postBookmarkInfo(post) { const data = post || {}; const bookmarkId = data.bookmark_id || (data.bookmark && data.bookmark.id) || null; return { bookmarked: !!(data.bookmarked || bookmarkId), bookmarkId }; } function topicNotificationOptions(level) { return TOPIC_NOTIFICATION_LEVELS.map((item) => `<option value="${item.value}"${item.value === level ? ' selected' : ''}>${item.label}</option>` ).join(''); } const POST_CONTEXT_ACTIONS = Object.freeze([ { name: 'share', event: 'post', action: 'share', label: '复制楼层链接', icon: 'link2' }, { name: 'report', event: 'reader', action: 'report', label: '举报', icon: 'flag', capability: 'canReport' }, { name: 'edit', event: 'post', action: 'edit', label: '编辑', icon: 'pencil', capability: 'canEdit' }, { name: 'bookmark', capability: 'canBookmark' }, { name: 'delete', event: 'post', action: 'delete', label: '删除', icon: 'trash', capability: 'canDelete' }, { name: 'assign', event: 'reader', action: 'assign-post', label: '指定楼层负责人', icon: 'userPlus', capability: 'canAssign' }, { name: 'admin', event: 'post', action: 'admin', label: '管理楼层', icon: 'wrench', capability: 'canAdmin' }, ]); function postContextActionsHtml(post, ctx) { const capabilities = topicActionCapabilities(post, ctx); const isTopicStarter = Number(post && post.post_number) === 1; const bookmarked = isTopicStarter ? !!(ctx && ctx.topicBookmarked) : postBookmarkInfo(post).bookmarked; const bookmarkButton = isTopicStarter ? `<button class="ldp-btn ldp-context-action ldp-topic-bookmark${bookmarked ? ' on' : ''}" type="button" data-topic-action="bookmark" aria-label="${bookmarked ? '已收藏本帖' : '收藏本帖'}" aria-pressed="${bookmarked ? 'true' : 'false'}"${capabilities.canBookmark ? '' : ' hidden'}>${icon('bookmark')}</button>` : `<button class="ldp-btn ldp-context-action ldp-post-bookmark${bookmarked ? ' on' : ''}" type="button" data-post-action="bookmark" aria-label="${bookmarked ? '已收藏该楼层' : '收藏该楼层'}" aria-pressed="${bookmarked ? 'true' : 'false'}"${capabilities.canBookmark ? '' : ' hidden'}>${icon('bookmark')}</button>`; return POST_CONTEXT_ACTIONS.map((item) => item.name === 'bookmark' ? bookmarkButton : `<button class="ldp-btn ldp-context-action ldp-post-${item.name}" type="button" data-${item.event}-action="${item.action}" aria-label="${item.label}"${!item.capability || capabilities[item.capability] ? '' : ' hidden'}>${icon(item.icon)}</button>` ).join('\n'); } function postContextActionCount(post, ctx) { const capabilities = topicActionCapabilities(post, ctx); return POST_CONTEXT_ACTIONS.filter((item) => !item.capability || capabilities[item.capability] ).length; } function compactPostActionsHtml(post, ctx) { const count = postContextActionCount(post, ctx); const eager = typeof matchMedia === 'function' && matchMedia('(hover: none)').matches; return ` <button class="ldp-btn ldp-replybtn" type="button" aria-label="回复">${icon('reply')}<span>回复</span></button> ${canShowBoostButton(post) ? `<button class="ldp-btn ldp-boostbtn" type="button" aria-label="boost">${icon('rocket')}</button>` : ''} <span class="ldp-context-actions-slot" data-ldp-context-actions="${eager ? '1' : '0'}" style="--ldp-context-action-count:${count}"${eager ? '' : ' aria-hidden="true"'}>${eager ? postContextActionsHtml(post, ctx) : ''}</span>`; } function hydratePostContextActions(postNode, ctx) { if (!postNode) return; const slot = postNode.querySelector(':scope > .ldp-reactions .ldp-context-actions-slot'); if (!slot || slot.dataset.ldpContextActions === '1') return; slot.innerHTML = postContextActionsHtml(postNode._ldpPostData || {}, ctx); slot.dataset.ldpContextActions = '1'; slot.removeAttribute('aria-hidden'); syncPostActionControls(postNode, ctx); } function nativeTopicSharedIssueVisible(topic) { if (!topic || topic.shared_issue_visible !== true) return false; const hasAcceptedAnswers = Array.isArray(topic.accepted_answers) && topic.accepted_answers.length > 0; const siteSettings = lookupDiscourse('service:site-settings'); return !hasAcceptedAnswers || siteSettings?.solved_allow_multiple_solutions === true; } function topicSharedIssueState(post, ctx) { const topic = (ctx && ctx.topicData) || {}; const count = Math.max(0, Number(topic.shared_issue_count) || 0); return { visible: ctx?.topicSharedIssueHostVisible === true && !SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.has(Number(ctx && ctx.topicId)), active: topic.user_created_shared_issue === true, count, isAuthor: !!ME_USERNAME && !!post && String(post.username || '') === String(ME_USERNAME), }; } function topicSharedIssueLabel(state) { return `俺也一样(${state.count})`; } function topicFooterActionsHtml(post, ctx) { const capabilities = topicActionCapabilities(post, ctx); const bookmarked = !!ctx.topicBookmarked; const sharedIssue = topicSharedIssueState(post, ctx); const sharedIssueLabel = topicSharedIssueLabel(sharedIssue); const level = Number.isInteger(ctx.topicNotificationLevel) ? ctx.topicNotificationLevel : topicNotificationLevel(ctx.topicData); return ` <div class="ldp-topic-footer-actions" aria-label="主题操作"> <button class="ldp-topic-footer-button ldp-topic-shared-issue${sharedIssue.active ? ' on' : ''}" type="button" data-topic-action="shared-issue" aria-label="${sharedIssueLabel}" aria-pressed="${sharedIssue.active ? 'true' : 'false'}"${sharedIssue.visible ? '' : ' hidden'}${sharedIssue.isAuthor ? ' disabled' : ''}>${icon('hand')}<span class="ldp-topic-shared-issue-label">俺也一样</span><span class="ldp-topic-shared-issue-count">(${sharedIssue.count})</span></button> <span class="ldp-topic-footer-separator" aria-hidden="true"${sharedIssue.visible ? '' : ' hidden'}></span> <button class="ldp-topic-footer-button ldp-topic-share" type="button" data-topic-action="share" aria-label="分享主题">${icon('share2')}<span>分享</span></button> <button class="ldp-topic-footer-button ldp-topic-bookmark${bookmarked ? ' on' : ''}" type="button" data-topic-action="bookmark" aria-label="${bookmarked ? '已添加主题书签' : '添加主题书签'}" aria-pressed="${bookmarked ? 'true' : 'false'}">${icon('bookmark')}<span>${bookmarked ? '已收藏' : '添加为书签'}</span></button> <button class="ldp-topic-footer-link ldp-topic-report" type="button" data-reader-action="report" aria-label="举报主题"${capabilities.canReport ? '' : ' hidden'}>${icon('flag')}<span>举报</span></button> <button class="ldp-topic-footer-link ldp-topic-assign" type="button" data-reader-action="assign-topic" aria-label="指定主题负责人"${capabilities.canAssign ? '' : ' hidden'}>${icon('userPlus')}<span>指定</span></button> <button class="ldp-topic-footer-button ldp-topic-reply ldp-replybtn" type="button" aria-label="回复主题">${icon('reply')}<span>回复</span></button> <span class="ldp-topic-notification" aria-label="通知:${escAttr((TOPIC_NOTIFICATION_LEVELS.find((item) => item.value === level) || TOPIC_NOTIFICATION_LEVELS[0]).label)}">${icon('bell')}<select class="ldp-reader-select ldp-topic-notification-select" aria-label="主题通知级别"><button></button>${topicNotificationOptions(level)}</select></span> </div>`; } function syncPostActionControls(postNode, ctx, options = {}) { if (!postNode) return; const post = postNode._ldpPostData || {}; const capabilities = topicActionCapabilities(post, ctx); const { bookmarked } = postBookmarkInfo(post); const actions = postNode.querySelector(':scope > .ldp-reactions .ldp-actions'); if (!actions) return; const contextSlot = actions.querySelector('.ldp-context-actions-slot'); if (contextSlot) { contextSlot.style.setProperty('--ldp-context-action-count', String(postContextActionCount(post, ctx))); } actions.querySelectorAll('.ldp-post-bookmark').forEach((button) => { button.classList.toggle('on', bookmarked); button.hidden = !capabilities.canBookmark; button.disabled = options.bookmarkBusy === true; button.setAttribute('aria-pressed', bookmarked ? 'true' : 'false'); button.setAttribute('aria-label', bookmarked ? '已收藏该楼层' : '收藏该楼层'); }); POST_CONTEXT_ACTIONS.forEach((item) => { if (!item.capability || item.name === 'bookmark') return; actions.querySelectorAll(`.ldp-post-${item.name}`).forEach((element) => { element.hidden = !capabilities[item.capability]; }); }); actions.querySelectorAll('.ldp-boostbtn').forEach((element) => { element.hidden = !canShowBoostButton(post); }); } function syncAllPostActionControls(ctx) { if (!ctx || !ctx.modal) return; ctx.modal.querySelectorAll('.ldp-post').forEach((postNode) => { syncPostActionControls(postNode, ctx); }); } function topicActionPostNodes(ctx) { const nodes = new Set(); const streamNode = ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(1); if (streamNode) nodes.add(streamNode); if (ctx && ctx.modal) { ctx.modal.querySelectorAll('.ldp-post[data-post-number="1"]').forEach((node) => nodes.add(node)); } return nodes; } function syncTopicActionControls(ctx, options = {}) { if (!ctx) return; const bookmarked = !!ctx.topicBookmarked; const level = Number.isInteger(ctx.topicNotificationLevel) ? ctx.topicNotificationLevel : topicNotificationLevel(ctx.topicData); topicActionPostNodes(ctx).forEach((postNode) => { const capabilities = topicActionCapabilities(postNode._ldpPostData, ctx); const sharedIssue = topicSharedIssueState(postNode._ldpPostData, ctx); const sharedIssueLabel = topicSharedIssueLabel(sharedIssue); postNode.querySelectorAll('.ldp-topic-shared-issue').forEach((button) => { button.hidden = !sharedIssue.visible; button.classList.toggle('on', sharedIssue.active); button.disabled = ctx.topicSharedIssueBusy === true || sharedIssue.isAuthor; button.setAttribute('aria-pressed', sharedIssue.active ? 'true' : 'false'); button.setAttribute('aria-label', sharedIssueLabel); const count = button.querySelector('.ldp-topic-shared-issue-count'); if (count) count.textContent = `(${sharedIssue.count})`; }); postNode.querySelectorAll('.ldp-topic-footer-separator').forEach((separator) => { separator.hidden = !sharedIssue.visible; }); postNode.querySelectorAll('.ldp-topic-bookmark').forEach((button) => { button.classList.toggle('on', bookmarked); button.setAttribute('aria-pressed', bookmarked ? 'true' : 'false'); button.setAttribute('aria-label', bookmarked ? '已收藏' : '收藏本帖'); const label = button.querySelector('span'); if (label) label.textContent = bookmarked ? '已收藏' : '添加为书签'; button.disabled = options.bookmarkBusy === true; }); postNode.querySelectorAll('.ldp-topic-report').forEach((element) => { element.hidden = !capabilities.canReport; }); postNode.querySelectorAll('.ldp-topic-assign').forEach((element) => { element.hidden = !capabilities.canAssign; }); postNode.querySelectorAll('.ldp-topic-notification').forEach((control) => { control.classList.toggle('busy', options.notificationBusy === true); const item = TOPIC_NOTIFICATION_LEVELS.find((option) => option.value === level); control.setAttribute('aria-label', `通知:${item ? item.label : '常规'}`); }); postNode.querySelectorAll('.ldp-topic-notification-select').forEach((select) => { select.value = String(level); select.disabled = options.notificationBusy === true || !ME_USERNAME; }); }); } function shouldCollapseNestedReply(p, isReply, options = {}) { if (!isReply || p.reply_count > 0) return false; return !!options.forceCollapsed; } function ensureParentJumpControl(post, parentNumber) { const head = post.querySelector(':scope > .ldp-post-head'); if (!head || head.querySelector(':scope > .ldp-jump-parent')) return; const currentFloor = head.querySelector(':scope > .ldp-floor'); const parentFloor = document.createElement('button'); parentFloor.className = 'ldp-floor ldp-jump-parent'; parentFloor.type = 'button'; parentFloor.dataset.targetPostNumber = String(parentNumber); parentFloor.setAttribute('aria-label', `跳到父楼层 #${parentNumber}`); parentFloor.textContent = `@#${parentNumber}`; if (currentFloor) currentFloor.classList.add('ldp-current-floor'); head.insertBefore(parentFloor, currentFloor || null); } function ensureStreamNestedReplyPresentation(post, parentPostNumber) { const parentNumber = +parentPostNumber; if (!post || !parentNumber) return; post.classList.add('ldp-reply', 'ldp-reply-collapsible'); post.dataset.replyToPostNumber = String(parentNumber); ensureNestedReplyToggle(post, post.firstChild); ensureParentJumpControl(post, parentNumber); } function isEscCollapsibleNestedReply(post) { if (!post || !post.classList.contains('ldp-reply-collapsible') || post.classList.contains('ldp-nested-preview')) return false; return !(+(post._ldpPostData?.reply_count || 0) > 0) && !(+(post.dataset.childReplyTotal || 0) > 0); } function syncCollapsedNestedReplyZebra(post, options = {}) { const ctx = post && post._ldpReaderContext; const postNumber = +(post && post.dataset.postNumber || 0); const item = ctx && ctx.streamItemMap && ctx.streamItemMap.get(postNumber); if (!item || item.node !== post) return; const excluded = !!post._ldpPostData?.hidden || post.classList.contains('ldp-nested-collapsed'); if (options.deferZebraSync) return; if (item.zebraExcluded === excluded) { if (excluded) post.classList.remove('ldp-zebra-alt'); return; } item.zebraExcluded = excluded; reindexStreamItems(ctx, Math.max(0, item.index)); if (ctx.onlyOp) invalidateStreamLayout(ctx); else markStreamPrefixDirty(ctx, Math.max(0, item.index)); refreshStreamZebra(ctx); scheduleStreamWindowSync(ctx); } function setNestedReplyCollapsed(post, collapsed, options = {}) { if (!post || (!post.classList.contains('ldp-reply-collapsible') && !post.classList.contains('ldp-hidden-collapsible'))) return; if (collapsed) { let line = null; let preview = post.previousElementSibling; if (preview && preview.classList.contains('ldp-floor-preview-link')) { line = preview; preview = preview.previousElementSibling; } if (line) line.classList.remove('open'); if (preview && preview.classList.contains('ldp-floor-preview')) preview.classList.remove('open'); } if (collapsed) post.querySelector(':scope > .ldp-nested-esc-hint')?.remove(); post.classList.toggle('ldp-nested-collapsed', collapsed); syncCollapsedNestedReplyZebra(post, options); const parentFloor = post.querySelector(':scope > .ldp-post-head > .ldp-jump-parent'); if (parentFloor) { const parentNumber = +parentFloor.dataset.targetPostNumber || 0; parentFloor.setAttribute( 'aria-label', collapsed ? `展开楼层并预览父楼层 #${parentNumber}` : `跳到父楼层 #${parentNumber}`, ); } const btn = post.querySelector(':scope > .ldp-nested-toggle'); if (!btn) return; const postNumber = post.dataset.postNumber || ''; const controlKey = `${collapsed ? 'collapsed' : 'open'}:${postNumber}`; if (btn.dataset.nestedControlKey !== controlKey) { btn.dataset.nestedControlKey = controlKey; btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); btn.setAttribute('aria-label', collapsed ? `展开楼层 #${postNumber}` : `收起楼层 #${postNumber}`); btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}<span>${collapsed ? '展开' : '收起'}</span>`; } setNestedReplyEscapeHint(post, !collapsed && isEscCollapsibleNestedReply(post)); } function setNestedReplyEscapeHint(post, visible) { if (!post) return; let hint = post.querySelector(':scope > .ldp-nested-esc-hint'); if (!visible) { hint?.remove(); return; } const toggle = post.querySelector(':scope > .ldp-nested-toggle'); if (!toggle) return; if (!hint) { hint = document.createElement('span'); hint.className = 'ldp-nested-esc-hint'; toggle.insertAdjacentElement('afterend', hint); } hint.textContent = 'Esc 收起'; } function alignCollapsedFloorPreview(preview, post, collapsedBottom, ctx) { if (!preview || !post || !ctx?.scrollRoot || !preview.isConnected || !post.isConnected || !Number.isFinite(collapsedBottom)) return; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const anchoredBottom = Math.max(rootRect.top, Math.min(rootRect.bottom, collapsedBottom)); const previewRect = preview.getBoundingClientRect(); const postRect = post.getBoundingClientRect(); const availableHeight = Math.max(0, anchoredBottom - rootRect.top); const combinedHeight = Math.max(0, postRect.bottom - previewRect.top); const correction = combinedHeight > availableHeight + 1 ? previewRect.top - rootRect.top : postRect.bottom - anchoredBottom; if (Math.abs(correction) < 1) return; const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop += correction; markStreamInternalScroll(ctx, previousScrollTop); scheduleStreamWindowSync(ctx); } function keepOnlyOpPostExpanded(post, ctx) { if (!post || !ctx || !ctx.onlyOp || !ctx.op || post._ldpPostData?.hidden || String(post.dataset.username || '') !== String(ctx.op)) return; setNestedReplyCollapsed(post, false); if (post.classList.contains('ldp-children-collapsed')) { post.classList.remove('ldp-children-collapsed'); syncChildControls(post); } } let activeJumpHighlight = null; function syncFirstPostJumpHighlightRegion(target) { if (!target || !target.matches('.ldp-post[data-post-number="1"]')) return null; const reactions = target.querySelector(':scope > .ldp-reactions'); if (!reactions) return null; const targetRect = target.getBoundingClientRect(); const reactionsRect = reactions.getBoundingClientRect(); const highlightHeight = Math.min( targetRect.height, Math.max(0, reactionsRect.bottom - targetRect.top) ); target.style.setProperty('--ldp-first-post-jump-highlight-height', `${Math.ceil(highlightHeight)}px`); return reactions; } function clearActiveJumpHighlight() { if (!activeJumpHighlight) return; clearTimeout(activeJumpHighlight.timer); if (activeJumpHighlight.resizeObserver) activeJumpHighlight.resizeObserver.disconnect(); activeJumpHighlight.target.classList.remove('ldp-jump-highlight'); activeJumpHighlight.target.style.removeProperty('--ldp-first-post-jump-highlight-height'); if (activeJumpHighlight.target.dataset.jumpHighlightToken === activeJumpHighlight.token) { delete activeJumpHighlight.target.dataset.jumpHighlightToken; } activeJumpHighlight = null; } function keepJumpHighlightUntilSettled(target) { if (!target) return; clearActiveJumpHighlight(); const token = `${Date.now()}-${Math.random()}`; target.classList.remove('ldp-jump-highlight'); target.style.removeProperty('--ldp-first-post-jump-highlight-height'); const firstPostReactions = syncFirstPostJumpHighlightRegion(target); void target.offsetWidth; target.dataset.jumpHighlightToken = token; target.classList.add('ldp-jump-highlight'); let resizeObserver = null; if (firstPostReactions && typeof ResizeObserver === 'function') { resizeObserver = new ResizeObserver(() => { if (target.isConnected && target.dataset.jumpHighlightToken === token) { syncFirstPostJumpHighlightRegion(target); } }); resizeObserver.observe(target); resizeObserver.observe(firstPostReactions); } const config = jumpHighlightConfigFromPrefs(PREFS); const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const lifetime = reducedMotion ? 1000 : jumpHighlightDurationMs(config) * config.count; const timer = setTimeout(() => { if (resizeObserver) resizeObserver.disconnect(); if (target.dataset.jumpHighlightToken === token) { target.classList.remove('ldp-jump-highlight'); target.style.removeProperty('--ldp-first-post-jump-highlight-height'); delete target.dataset.jumpHighlightToken; } if (activeJumpHighlight && activeJumpHighlight.token === token) activeJumpHighlight = null; }, lifetime); activeJumpHighlight = { target, token, timer, resizeObserver }; } function shouldSmoothScrollToPost(target, scrollRoot) { if (!target || !scrollRoot) return true; const rootRect = scrollRoot.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const targetCenter = targetRect.top + targetRect.height / 2; const rootCenter = rootRect.top + rootRect.height / 2; const distance = Math.abs(targetCenter - rootCenter); const smoothDistance = Math.max(scrollRoot.clientHeight * 2.5, 1200); return distance <= smoothDistance; } function streamPostNumber(node) { return +(node && node.dataset && node.dataset.postNumber) || 0; } function streamOwnerPostNode(ctx, node) { if (!ctx || !ctx.commentsEl || !node) return null; let owner = node; while (owner && owner.parentElement !== ctx.commentsEl) { owner = owner.parentElement && owner.parentElement.closest('.ldp-post'); } return owner && owner.parentElement === ctx.commentsEl ? owner : null; } function clearStreamProgrammaticTarget(ctx) { if (!ctx) return; if (ctx.streamProgrammaticTarget && ctx.streamProgrammaticTarget.animationFrame) { cancelAnimationFrame(ctx.streamProgrammaticTarget.animationFrame); } ctx.streamProgrammaticTarget = null; ctx.streamWindowResetPending = false; } function beginReaderNavigation(ctx) { if (!ctx) return 0; clearStreamViewportAnchor(ctx); clearStreamProgrammaticTarget(ctx); ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1; return ctx.readerNavigationSequence; } function cancelReaderNavigation(ctx) { if (!ctx) return; clearStreamViewportAnchor(ctx); clearStreamProgrammaticTarget(ctx); ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1; } function readerNavigationIsCurrent(ctx, sequence) { if (!ctx || !sequence || +ctx.readerNavigationSequence !== +sequence) return false; return !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active'); } function waitForReaderJumpLayout() { return new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); }); } function waitForDelay(delay) { return new Promise((resolve) => { setTimeout(resolve, delay); }); } async function waitForReaderInitialLayout(overlay) { await waitForReaderJumpLayout(); const modal = overlay && overlay.querySelector('.ldp-modal'); if (!modal || !overlay.classList.contains('ldp-reader-embedded') || typeof modal.getAnimations !== 'function') return; const motions = modal.getAnimations().filter((motion) => motion.playState !== 'finished'); if (!motions.length) return; await Promise.race([ Promise.allSettled(motions.map((motion) => motion.finished)), waitForDelay(400), ]); await waitForReaderJumpLayout(); } function readerTargetNodeIsVisible(target, ctx) { if (!target || !target.isConnected) return false; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); return targetRect.bottom > rootRect.top + 1 && targetRect.top < rootRect.bottom - 1; } function stableReaderViewportOffset(target, ctx, offset) { const requestedOffset = Number(offset); if (!Number.isFinite(requestedOffset) || !target || !target.isConnected || !ctx || !ctx.scrollRoot) return 0; const rootHeight = Math.max(0, ctx.scrollRoot.getBoundingClientRect().height); const targetHeight = Math.max(0, target.getBoundingClientRect().height); if (!rootHeight || !targetHeight) return requestedOffset; const minimumVisibleHeight = Math.min(40, rootHeight, targetHeight); return Math.max( minimumVisibleHeight - targetHeight, Math.min(rootHeight - minimumVisibleHeight, requestedOffset), ); } function beginStreamProgrammaticTarget(ctx, postNumber, options = {}) { if (!ctx || !postNumber) return null; clearStreamViewportAnchor(ctx); const target = { postNumber: +postNumber, alignBottom: options.alignBottom === true, alignCenter: options.alignCenter === true, alignBottomToCenter: options.alignBottomToCenter === true, visualNode: options.visualNode || null, animationFrame: 0, }; ctx.streamProgrammaticTarget = target; ctx.streamScrollDirection = 0; ctx.streamScrollVelocity = 0; return target; } function markStreamInternalScroll(ctx, previousScrollTop = NaN) { if (!ctx || !ctx.scrollRoot) return; const nextScrollTop = ctx.scrollRoot.scrollTop; if (Number.isFinite(previousScrollTop)) { const appliedDelta = nextScrollTop - previousScrollTop; if (Math.abs(appliedDelta) >= 0.01) { const pendingDelta = Number.isFinite(ctx.streamInternalScrollDelta) ? ctx.streamInternalScrollDelta : 0; ctx.streamInternalScrollDelta = pendingDelta + appliedDelta; } } ctx.streamInternalScrollTop = nextScrollTop; } function alignStreamProgrammaticTarget(ctx, state) { if (!ctx || !state || ctx.streamProgrammaticTarget !== state) return false; const target = state.visualNode && state.visualNode.isConnected ? state.visualNode : getStreamPostNode(ctx, state.postNumber); if (!target || !target.isConnected) return false; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const correction = state.alignBottomToCenter ? targetRect.bottom - (rootRect.top + rootRect.height / 2) : state.alignBottom ? targetRect.bottom - rootRect.bottom : state.alignCenter ? targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2) : targetRect.top - rootRect.top; if (Math.abs(correction) >= 1) { const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop += correction; markStreamInternalScroll(ctx, previousScrollTop); } return true; } function animateStreamProgrammaticTarget(ctx, state) { if (!ctx || !state || ctx.streamProgrammaticTarget !== state) return false; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { const aligned = alignStreamProgrammaticTarget(ctx, state); clearStreamProgrammaticTarget(ctx); scheduleStreamWindowSync(ctx); return aligned; } const initialTarget = state.visualNode && state.visualNode.isConnected ? state.visualNode : getStreamPostNode(ctx, state.postNumber, { mount: true }); if (!initialTarget || !initialTarget.isConnected) { clearStreamProgrammaticTarget(ctx); return false; } state.visualNode = initialTarget; const initialRootRect = ctx.scrollRoot.getBoundingClientRect(); const initialTargetRect = initialTarget.getBoundingClientRect(); const viewportHeight = Math.max(1, initialRootRect.height); const travelDistance = Math.abs( initialTargetRect.bottom - (initialRootRect.top + viewportHeight / 2) ); const extraDistance = Math.max(0, travelDistance - viewportHeight / 2); const duration = Math.round(220 + Math.min(1, extraDistance / viewportHeight) * 80); const startedAt = performance.now(); const startScrollTop = ctx.scrollRoot.scrollTop; const step = (now) => { if (ctx.streamProgrammaticTarget !== state) return; const target = state.visualNode && state.visualNode.isConnected ? state.visualNode : getStreamPostNode(ctx, state.postNumber, { mount: true }); if (!target || !target.isConnected) { clearStreamProgrammaticTarget(ctx); return; } state.visualNode = target; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const targetScrollTop = Math.max( 0, ctx.scrollRoot.scrollTop + targetRect.bottom - (rootRect.top + rootRect.height / 2) ); const progress = Math.min(1, Math.max(0, (now - startedAt) / duration)); const eased = 1 - Math.pow(1 - progress, 4); const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop = startScrollTop + (targetScrollTop - startScrollTop) * eased; markStreamInternalScroll(ctx, previousScrollTop); scheduleStreamWindowSync(ctx); if (progress < 1) { state.animationFrame = requestAnimationFrame(step); return; } state.animationFrame = 0; alignStreamProgrammaticTarget(ctx, state); clearStreamProgrammaticTarget(ctx); }; state.animationFrame = requestAnimationFrame(step); return true; } function findTopVisibleStreamPost(ctx) { if (!ctx || !ctx.scrollRoot || !ctx.commentsEl) return null; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const allPosts = Array.from(ctx.commentsEl.querySelectorAll(':scope > .ldp-post:not(.ldp-floor-preview)')); let posts = allPosts; const items = streamLayoutItems(ctx); rebuildStreamPrefix(ctx); if (items.length && Number.isFinite(ctx.streamListOffset)) { const localTop = Math.max(0, ctx.scrollRoot.scrollTop - ctx.streamListOffset); const index = findStreamIndexAtOffset(ctx.streamPrefix, localTop); const nearby = []; for (let cursor = Math.max(0, index - 2); cursor < Math.min(items.length, index + 7); cursor++) { const node = items[cursor] && items[cursor].node; if (node && node.parentElement === ctx.commentsEl) nearby.push(node); } if (nearby.length) posts = nearby; } const findBest = (candidates) => { let best = null; candidates.forEach((post) => { const marker = post.querySelector(':scope > .ldp-post-head'); if (!marker) return; const rect = marker.getBoundingClientRect(); if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return; const ownerNode = streamOwnerPostNode(ctx, post); if (!streamPostNumber(ownerNode)) return; const score = Math.max(0, rect.top - rootRect.top); if (!best || score < best.score || (score === best.score && rect.top < best.top)) { best = { marker, top: rect.top, ownerNode, score }; } }); if (best) return best; candidates.forEach((post) => { const rect = post.getBoundingClientRect(); if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return; const ownerNode = streamOwnerPostNode(ctx, post); if (!streamPostNumber(ownerNode)) return; const score = Math.abs(rect.top - rootRect.top); if (!best || score < best.score) best = { marker: post, ownerNode, score }; }); return best; }; let best = findBest(posts); if (!best && posts !== allPosts) best = findBest(allPosts); return best && { marker: best.marker, ownerNode: best.ownerNode }; } function findStreamViewportAnchorMarker(ctx) { const visible = findTopVisibleStreamPost(ctx); const owner = visible && visible.ownerNode; if (!owner || !owner.isConnected) return null; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const viewportEdge = rootRect.top + 1; const candidates = [visible.marker, owner, ...owner.querySelectorAll( ':scope > .ldp-post-head, :scope > .ldp-content > *, :scope > .ldp-reactions, ' + ':scope > .ldp-boost-list > .ldp-boost-bubble, :scope > .ldp-children > .ldp-post, ' + ':scope > .ldp-children > .ldp-post > .ldp-post-head, :scope > .ldp-sub-actions' )]; let best = null; const seen = new Set(); candidates.forEach((candidate) => { if (!candidate || seen.has(candidate) || !candidate.isConnected) return; seen.add(candidate); const rect = candidate.getBoundingClientRect(); if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return; const crossesTop = rect.top <= viewportEdge && rect.bottom > viewportEdge; const distance = crossesTop ? 0 : Math.max(0, rect.top - viewportEdge); if (!best || distance < best.distance || (distance === best.distance && rect.height < best.height)) { best = { marker: candidate, distance, height: rect.height }; } }); return best && best.marker; } function clearStreamViewportAnchor(ctx) { if (!ctx) return; ctx.streamViewportAnchorRestorePending = false; ctx.streamViewportAnchor = null; if (ctx.scrollRoot) ctx.scrollRoot.classList.remove('ldp-stream-viewport-anchor'); } function captureStreamViewportAnchor(ctx) { if (!ctx || !ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) return null; const marker = findStreamViewportAnchorMarker(ctx); if (!marker) { clearStreamViewportAnchor(ctx); return null; } const rootRect = ctx.scrollRoot.getBoundingClientRect(); const markerRect = marker.getBoundingClientRect(); const markerPost = marker.closest('.ldp-post'); const owner = streamOwnerPostNode(ctx, markerPost); const state = { marker, offset: markerRect.top - rootRect.top, owner, ownerOffset: owner ? owner.getBoundingClientRect().top - rootRect.top : 0, }; ctx.streamViewportAnchor = state; ctx.scrollRoot.classList.add('ldp-stream-viewport-anchor'); return state; } function beginStreamViewportAnchorRestoreBatch(ctx) { if (!ctx) return; ctx.streamViewportAnchorRestoreDepth = +(ctx.streamViewportAnchorRestoreDepth || 0) + 1; } function endStreamViewportAnchorRestoreBatch(ctx) { if (!ctx || !ctx.streamViewportAnchorRestoreDepth) return; ctx.streamViewportAnchorRestoreDepth--; if (ctx.streamViewportAnchorRestoreDepth || !ctx.streamViewportAnchorRestorePending) return; ctx.streamViewportAnchorRestorePending = false; restoreStreamViewportAnchor(ctx); } function applyStreamViewportAnchorRestore(ctx) { const state = ctx && ctx.streamViewportAnchor; if (!state) return false; if (!ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) { clearStreamViewportAnchor(ctx); return false; } const markerConnected = state.marker && state.marker.isConnected && ctx.commentsEl.contains(state.marker); const ownerConnected = state.owner && state.owner.isConnected && ctx.commentsEl.contains(state.owner); const marker = markerConnected ? state.marker : ownerConnected ? state.owner : null; if (!marker) { clearStreamViewportAnchor(ctx); return false; } const expectedOffset = marker === state.marker ? state.offset : state.ownerOffset; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const markerRect = marker.getBoundingClientRect(); const correction = markerRect.top - rootRect.top - expectedOffset; if (Math.abs(correction) < 0.5) return true; const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop += correction; if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5) { markStreamInternalScroll(ctx, previousScrollTop); } else if (marker === state.marker) { state.offset = markerRect.top - rootRect.top; } else { state.ownerOffset = markerRect.top - rootRect.top; } return true; } function restoreStreamViewportAnchor(ctx) { if (!ctx || !ctx.streamViewportAnchor) return false; if (ctx.streamViewportAnchorRestoreDepth) { ctx.streamViewportAnchorRestorePending = true; return true; } ctx.streamViewportAnchorRestorePending = false; return applyStreamViewportAnchorRestore(ctx); } function forEachDirectNestedPost(node, callback) { const children = node && node.querySelector(':scope > .ldp-children'); if (!children) return; for (const child of children.children) { if (child.classList.contains('ldp-post')) callback(child); } } function createStreamSpacer(position) { const spacer = document.createElement('div'); spacer.className = `ldp-stream-spacer ldp-stream-spacer-${position}`; spacer.setAttribute('aria-hidden', 'true'); setStreamSpacerHeight(spacer, 0); return spacer; } function setStreamSpacerHeight(spacer, height) { if (!spacer) return; const next = Math.max(0, Math.round(+height || 0)); const value = `${next}px`; if (spacer.style.getPropertyValue('--ldp-stream-spacer-height') === value) return; spacer.style.setProperty('--ldp-stream-spacer-height', value); } function ensureStreamScaffold(ctx) { if (!ctx || !ctx.commentsEl || !ctx.streamTopSpacer || !ctx.streamBottomSpacer) return; if (!ctx.streamTopSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamTopSpacer); if (!ctx.streamBottomSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamBottomSpacer); } function streamItemMatchesFilter(ctx, item) { if (!ctx || !ctx.onlyOp) return true; return !!item && !!ctx.op && String(item.username || '') === String(ctx.op); } function streamLayoutItems(ctx) { if (!ctx || !ctx.onlyOp) return (ctx && ctx.streamItems) || []; if (!ctx.streamLayoutDirty && Array.isArray(ctx.streamLayoutItems)) return ctx.streamLayoutItems; const items = ctx.streamItems.filter((item) => streamItemMatchesFilter(ctx, item)); let zebraIndex = 0; items.forEach((item, index) => { item.layoutIndex = index; item.layoutZebraIndex = streamItemParticipatesInZebra(item) ? zebraIndex++ : -1; }); ctx.streamLayoutItems = items; ctx.streamLayoutDirty = false; return items; } function invalidateStreamLayout(ctx) { if (!ctx) return; ctx.streamLayoutDirty = true; ctx.streamPrefixDirty = true; ctx.streamPrefixDirtyFrom = 0; ctx.streamListOffset = NaN; ctx.streamNeedsRepair = true; } function markStreamPrefixDirty(ctx, index = 0) { const items = streamLayoutItems(ctx); const next = Math.max(0, Math.min(items.length, +index || 0)); ctx.streamPrefixDirty = true; ctx.streamPrefixDirtyFrom = Number.isInteger(ctx.streamPrefixDirtyFrom) ? Math.min(ctx.streamPrefixDirtyFrom, next) : next; } function rebuildStreamPrefix(ctx) { if (!ctx || !ctx.streamItems) return [0]; const items = streamLayoutItems(ctx); if (!ctx.streamPrefixDirty && ctx.streamPrefix.length === items.length + 1) return ctx.streamPrefix; const prefix = ctx.streamPrefix; let from = Number.isInteger(ctx.streamPrefixDirtyFrom) ? ctx.streamPrefixDirtyFrom : 0; if (!prefix.length || prefix[0] !== 0 || prefix.length > items.length + 1) from = 0; from = Math.max(0, Math.min(from, items.length, prefix.length - 1)); if (from === 0) { prefix.length = 1; prefix[0] = 0; } else prefix.length = from + 1; for (let index = from; index < items.length; index++) { const item = items[index]; prefix[index + 1] = prefix[index] + Math.max(+(item.height || 0), STREAM_PLACEHOLDER_MIN_HEIGHT); } ctx.streamPrefixDirty = false; ctx.streamPrefixDirtyFrom = null; return prefix; } function findStreamIndexAtOffset(prefix, offset) { if (!prefix || prefix.length <= 1) return 0; const target = Math.max(0, +offset || 0); let low = 0; let high = prefix.length - 2; while (low < high) { const mid = Math.floor((low + high) / 2); if (prefix[mid + 1] <= target) low = mid + 1; else high = mid; } return low; } function createStreamResizeObserver(ctx) { if (typeof ResizeObserver === 'undefined') { return { observe() {}, unobserve() {}, disconnect() {} }; } return new ResizeObserver((entries) => { let changed = false; entries.forEach((entry) => { const item = ctx.streamItemMap.get(streamPostNumber(entry.target)); if (!item) return; const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize; const measured = (borderBox && borderBox.blockSize) || entry.target.offsetHeight || entry.contentRect.height; const height = Math.max(Math.round(measured || 0), STREAM_PLACEHOLDER_MIN_HEIGHT); if (entry.target.classList.contains('ldp-has-child-branches')) { scheduleNestedBranchSync(entry.target, ctx); } if (Math.abs(height - item.height) < 1) return; item.height = height; markStreamPrefixDirty(ctx, ctx.onlyOp ? item.layoutIndex : item.index); changed = true; }); if (!changed) return; restoreStreamViewportAnchor(ctx); ctx.streamNeedsRepair = true; scheduleStreamWindowSync(ctx); }); } function updateStreamSpacerHeights(ctx, start, end) { const total = streamLayoutItems(ctx).length; const totalHeight = ctx.streamPrefix[total] || 0; setStreamSpacerHeight( ctx.streamTopSpacer, Math.max(ctx.streamPrefix[start], +(ctx.initialTargetHeadSpacerHeight || 0)) ); setStreamSpacerHeight( ctx.streamBottomSpacer, Math.max(0, totalHeight - ctx.streamPrefix[end]) ); } function clearInitialTargetHeadSpacer(ctx) { if (!ctx || !ctx.initialTargetHeadSpacerHeight) return; ctx.initialTargetHeadSpacerHeight = 0; updateStreamSpacerHeights(ctx, ctx.streamWindowStart, ctx.streamWindowEnd); } function createPostIdleWorkState(pendingSet) { return { cancels: new WeakMap(), pendingSet }; } const postContentHydrationWork = createPostIdleWorkState('pendingPostHydrations'); const postLatexRenderWork = createPostIdleWorkState('pendingPostLatexRenders'); function cancelPostIdleWork(post, ctx, work, pendingKey) { if (!post) return; const cancel = work.cancels.get(post); if (cancel) cancel(); work.cancels.delete(post); if (ctx && ctx[work.pendingSet]) ctx[work.pendingSet].delete(post); delete post.dataset[pendingKey]; } function cancelPostContentHydration(post, ctx) { cancelPostIdleWork(post, ctx, postContentHydrationWork, 'ldpContentHydrationPending'); } function cancelPostLatexRender(post, ctx) { cancelPostIdleWork(post, ctx, postLatexRenderWork, 'ldpLatexPending'); } function postContentPlaceholderHeight(postData) { const cooked = String((postData && postData.cooked) || ''); if (!cooked) return 72; const blockCount = (cooked.match(/<(?:p|li|pre|blockquote|h[1-6]|tr|br)\b/gi) || []).length; const lineEstimate = Math.max(2, blockCount, Math.ceil(cooked.length / 180)); const mediaHeight = /<(?:img|video|audio|iframe)\b|class=["'][^"']*(?:onebox|lightbox|d-image-grid)/i.test(cooked) ? 220 : 0; return Math.max(72, Math.min(460, 28 + lineEstimate * 22 + mediaHeight)); } function postHasBoostData(boosts) { if (Array.isArray(boosts)) return boosts.some(Boolean); return !!boosts; } function hydratePostContent(post, ctx) { if (!post || post.dataset.ldpContentHydrated !== '0') return !!post; const postData = post._ldpPostData || {}; const content = post.querySelector(':scope > .ldp-content'); if (!content) return false; cancelPostContentHydration(post, ctx); setPostCookedContent(content, postData.cooked, postData.link_counts); content.classList.remove('ldp-content-pending'); content.style.removeProperty('--ldp-content-placeholder-height'); content.removeAttribute('aria-busy'); renderPostPolls(post, ctx); renderPostSpecialContent(post, ctx); const pendingBoosts = post.querySelector(':scope > .ldp-boost-list.ldp-boost-pending'); if (pendingBoosts) { const holder = document.createElement('template'); holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx }); const next = holder.content.firstElementChild; if (next) replaceOwnedNode(pendingBoosts, next); else removeOwnedNode(pendingBoosts); } post.dataset.ldpContentHydrated = '1'; if (ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(+post.dataset.postNumber) === post) { if (ctx.streamHydratedNodes) ctx.streamHydratedNodes.add(post); } delete post.dataset.ldpLatexRendered; bindPostMediaLayoutSync(post, ctx); if (post.isConnected) { schedulePostLatexRender(post, ctx); scheduleNestedBranchSync(post, ctx); } restoreReaderQuoteHighlightForPost(post, ctx); restoreStreamViewportAnchor(ctx); return true; } function dehydratePostContent(post, ctx) { if (!post || post.isConnected || post.dataset.ldpContentHydrated !== '1') return false; if (ctx && ctx.floorPreview && ctx.floorPreview.isOpen()) return false; cancelPostContentHydration(post, ctx); cancelPostLatexRender(post, ctx); const postData = post._ldpPostData || {}; const content = post.querySelector(':scope > .ldp-content'); if (content) { suspendReaderMedia(content); releasePersistentAvatarImages(content); content.replaceChildren(); content.classList.add('ldp-content-pending'); content.style.setProperty('--ldp-content-placeholder-height', `${postContentPlaceholderHeight(postData)}px`); content.setAttribute('aria-busy', 'true'); } const boosts = post.querySelector(':scope > .ldp-boost-list'); if (boosts) { destroyReaderMedia(boosts); releasePersistentAvatarImages(boosts); boosts.replaceChildren(); boosts.classList.add('ldp-boost-pending'); boosts.hidden = !postHasBoostData(postData.boosts); boosts.setAttribute('aria-hidden', 'true'); } post.dataset.ldpContentHydrated = '0'; delete post.dataset.ldpLatexRendered; delete post.dataset.ldpLatexPending; if (ctx && ctx.streamHydratedNodes) ctx.streamHydratedNodes.delete(post); return true; } function pruneStreamHydratedContent(ctx, items, start, end) { if (!ctx || !ctx.streamHydratedNodes || ctx.floorPreview && ctx.floorPreview.isOpen()) return; const warmBuffer = Math.min(24, Math.max(8, Math.ceil(streamPerformance(ctx).streamMaxItems / 4))); const warmStart = Math.max(0, start - warmBuffer); const warmEnd = Math.min(items.length, end + warmBuffer); Array.from(ctx.streamHydratedNodes).forEach((post) => { if (!post || post.isConnected) return; const item = ctx.streamItemMap.get(streamPostNumber(post)); const index = item && (ctx.onlyOp ? item.layoutIndex : item.index); const participates = item && (!ctx.onlyOp || streamItemMatchesFilter(ctx, item)); if (!participates || !Number.isInteger(index) || index < warmStart || index >= warmEnd) { dehydratePostContent(post, ctx); } }); } function hydrateStreamNavigationNeighborhood(ctx, postNumber) { const targetItem = ctx && ctx.streamItemMap && ctx.streamItemMap.get(+postNumber); if (!targetItem) return; const items = streamLayoutItems(ctx); const targetIndex = ctx.onlyOp ? targetItem.layoutIndex : targetItem.index; if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= items.length) return; const span = Math.min(items.length, streamPerformance(ctx).streamMaxItems); const beforeTarget = Math.min(targetIndex, Math.floor(span * 0.75)); let start = Math.max(0, targetIndex - beforeTarget); const end = Math.min(items.length, start + span); start = Math.max(0, end - span); for (let index = start; index < end; index++) { const item = items[index]; if (item && item.node) hydratePostContent(item.node, ctx); } } function schedulePostIdleWork(post, ctx, work, options) { const { pendingKey, delay, settleMs, isPending, run } = options; if (!post || !isPending() || post.dataset[pendingKey] === '1') return; if (options.immediate && !streamScrollIsActive(ctx, settleMs)) return run(); post.dataset[pendingKey] = '1'; const cancel = () => cancelPostIdleWork(post, ctx, work, pendingKey); const queue = () => { work.cancels.set(post, scheduleIdleWork(attempt, delay)); if (ctx && ctx[work.pendingSet]) ctx[work.pendingSet].add(post); }; const attempt = () => { if (!isPending() || !post.isConnected) return cancel(); if (streamScrollIsActive(ctx, settleMs)) return queue(); cancel(); run(); }; queue(); } function schedulePostContentHydration(post, ctx, immediate = false) { schedulePostIdleWork(post, ctx, postContentHydrationWork, { pendingKey: 'ldpContentHydrationPending', delay: 320, settleMs: 180, immediate, isPending: () => post.dataset.ldpContentHydrated === '0', run: () => hydratePostContent(post, ctx), }); } function hydrateStreamRangeContent(ctx, items, start, end) { if (!ctx || !items || start >= end) return; const pressure = scrollLoadPressure(ctx); const beforeLead = ctx.streamScrollDirection < 0 ? Math.min(8, Math.max(3, pressure.lookahead)) : 2; const afterLead = ctx.streamScrollDirection > 0 ? Math.min(6, Math.max(2, pressure.lookahead)) : 2; const priorityStart = Math.max(start, ctx.streamViewportTopIndex - beforeLead); const priorityEnd = Math.min(end - 1, ctx.streamViewportBottomIndex + afterLead); const idleStart = Math.max(start, priorityStart - 3); const idleEnd = Math.min(end - 1, priorityEnd + 3); const initialTargetPostNumber = +(ctx.initialTargetHydrationBoundary || 0); const initialTargetItem = initialTargetPostNumber && ctx.streamItemMap.get(initialTargetPostNumber); const initialTargetIndex = initialTargetItem ? (ctx.onlyOp ? initialTargetItem.layoutIndex : initialTargetItem.index) : -1; beginStreamViewportAnchorRestoreBatch(ctx); try { for (let index = idleStart; index <= idleEnd; index++) { const item = items[index]; if (!item || !item.node) continue; const beforeInitialTarget = initialTargetPostNumber && ( initialTargetIndex >= 0 ? index < initialTargetIndex : item.postNumber < initialTargetPostNumber ); if (beforeInitialTarget && (ctx.initialTargetPrecedingContentDeferred || ctx.initialTargetPositionPending)) continue; const priority = index >= priorityStart && index <= priorityEnd; schedulePostContentHydration(item.node, ctx, priority); } } finally { endStreamViewportAnchorRestoreBatch(ctx); } } function tryOwnedNodeMutation(mutation) { try { mutation(); return true; } catch (error) { if (error && error.name === 'NotFoundError') return false; throw error; } } function removeOwnedNode(node, owner = null) { if (!node) return false; const parent = node.parentNode; if (!parent || (owner && parent !== owner)) return false; releasePersistentAvatarImages(node); return tryOwnedNodeMutation(() => parent.removeChild(node)); } function moveOwnedNodeBefore(owner, node, referenceNode = null) { if (!owner || !node) return false; const reference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null; if (node.parentNode === owner && node.nextSibling === reference) return true; try { owner.insertBefore(node, reference); return true; } catch (error) { if (!error || error.name !== 'NotFoundError') throw error; const freshReference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null; if (node.parentNode === owner && node.nextSibling === freshReference) return true; owner.insertBefore(node, freshReference); return true; } } function replaceOwnedNode(currentNode, nextNode) { if (!currentNode || !nextNode) return false; const parent = currentNode.parentNode; if (!parent) return false; return tryOwnedNodeMutation(() => parent.replaceChild(nextNode, currentNode)); } function mountStreamRange(ctx, start, end) { if (!ctx || !ctx.streamItems || !ctx.commentsEl) return; ensureStreamScaffold(ctx); rebuildStreamPrefix(ctx); const items = streamLayoutItems(ctx); const total = items.length; start = Math.max(0, Math.min(total, +start || 0)); end = Math.max(start, Math.min(total, +end || 0)); hydrateStreamRangeContent(ctx, items, start, end); const firstRangeNode = items[start] && items[start].node; const lastRangeNode = items[end - 1] && items[end - 1].node; const rangeEndpointsConnected = start === end || (firstRangeNode && lastRangeNode && firstRangeNode.parentElement === ctx.commentsEl && lastRangeNode.parentElement === ctx.commentsEl); if (ctx.streamMountedItemCount === total && ctx.streamWindowStart === start && ctx.streamWindowEnd === end && ctx.streamMountedNodes.size === end - start && rangeEndpointsConnected) { updateStreamSpacerHeights(ctx, start, end); syncCommentsHeaderPlacement(ctx); restoreStreamViewportAnchor(ctx); return; } const activeElement = readerActiveElement(); const activePost = ctx.commentsEl.contains(activeElement) && activeElement.closest('.ldp-post'); if (activePost) { const activeItem = ctx.streamItemMap.get(streamPostNumber(activePost)); const activeIndex = activeItem && (ctx.onlyOp ? activeItem.layoutIndex : activeItem.index); if (activeItem && (activeIndex < start || activeIndex >= end) && activeElement.blur) { activeElement.blur(); } } const rangeItems = items.slice(start, end); const desired = new Set(rangeItems.map((item) => item.node)); Array.from(ctx.streamMountedNodes).forEach((node) => { if (desired.has(node)) return; ctx.streamResizeObserver.unobserve(node); if (ctx.tracker && ctx.tracker.unobserve) { ctx.tracker.unobserve(node); forEachDirectNestedPost(node, (child) => ctx.tracker.unobserve(child)); } if (ctx.repliesIO && ctx.repliesIO.unobserve) ctx.repliesIO.unobserve(node); cancelPostContentHydration(node, ctx); cancelPostLatexRender(node, ctx); suspendReaderMedia(node); const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node]; bundle.forEach((part) => removeOwnedNode(part, ctx.commentsEl)); if (node.querySelector(':scope > .ldp-content :is(video,audio,iframe),:scope > .ldp-content .ldp-media-carousel')) { dehydratePostContent(node, ctx); } ctx.streamMountedNodes.delete(node); }); pruneStreamHydratedContent(ctx, items, start, end); let cursor = ctx.streamTopSpacer.nextSibling; rangeItems.forEach((item) => { const node = item.node; const wasConnected = node.parentElement === ctx.commentsEl; const deferPreTargetWork = ctx.initialTargetPrecedingContentDeferred && +item.postNumber < +(ctx.initialTargetHydrationBoundary || 0); if (!wasConnected) node.classList.remove('ldp-nested-branches-ready'); keepOnlyOpPostExpanded(node, ctx); ctx.streamMountedNodes.add(node); const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node]; bundle.forEach((part) => { if (part !== cursor) { const reference = cursor && cursor.parentNode === ctx.commentsEl ? cursor : (ctx.streamBottomSpacer.parentNode === ctx.commentsEl ? ctx.streamBottomSpacer : null); moveOwnedNodeBefore(ctx.commentsEl, part, reference); } cursor = part.nextSibling; }); if (document.visibilityState === 'visible') activateReaderMedia(node); restoreReaderQuoteHighlightForPost(node, ctx); if (node.dataset.ldpContentHydrated === '1' && node.dataset.ldpLatexRendered !== '1') { schedulePostLatexRender(node, ctx); } if (!wasConnected) { hydratePersistentAvatarImages(node); syncReaderSvgSymbols(node); ctx.streamResizeObserver.observe(node); ctx.tracker.observe(node); forEachDirectNestedPost(node, (child) => ctx.tracker.observe(child)); if (!deferPreTargetWork && node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) { ctx.repliesIO.observe(node); } const renderedChildren = node.querySelector(':scope > .ldp-children > .ldp-post'); if (renderedChildren) scheduleNestedBranchSync(node, ctx); } }); updateStreamSpacerHeights(ctx, start, end); ctx.streamWindowStart = start; ctx.streamWindowEnd = end; ctx.streamMountedItemCount = total; refreshStreamZebra(ctx, rangeItems.map((item) => item.node)); syncCommentsHeaderPlacement(ctx); if (ctx.floorPreview && ctx.floorPreview.isOpen()) scheduleFloorPreviewSync(ctx); restoreStreamViewportAnchor(ctx); } function streamPerformance(ctx) { return (ctx && ctx.performance) || BALANCED_PERFORMANCE; } function streamScrollIsActive(ctx, settleMs = 120) { if (!ctx || !ctx.streamLastScrollAt) return false; return Date.now() - ctx.streamLastScrollAt < settleMs && +(ctx.streamScrollVelocity || 0) > 0.08; } function schedulePostLatexRender(post, ctx) { schedulePostIdleWork(post, ctx, postLatexRenderWork, { pendingKey: 'ldpLatexPending', delay: 360, settleMs: 120, immediate: true, isPending: () => post.dataset.ldpLatexRendered !== '1', run: () => { renderLatexInPost(post); restoreReaderQuoteHighlightForPost(post, ctx); }, }); } function scrollLoadPressure(ctx) { const lastScrollAt = +(ctx && ctx.streamLastScrollAt || 0); const age = Date.now() - lastScrollAt; const viewportHeight = Math.max(1, +(ctx && ctx.scrollRoot && ctx.scrollRoot.clientHeight || 0)); const viewportsPerSecond = age > 300 ? 0 : Math.max(0, +(ctx && ctx.streamScrollVelocity || 0)) * 1000 / viewportHeight; if (viewportsPerSecond >= 0.75) return { batches: 2, lookahead: viewportsPerSecond >= 2.5 ? 24 : 14 }; return { batches: 1, lookahead: 8 }; } function repairBlankStreamViewport(ctx, start, end) { if (!ctx || !ctx.streamMountedNodes.size) return; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const items = streamLayoutItems(ctx); let firstNode = items[start] && items[start].node; if (!firstNode || !firstNode.isConnected) return; let firstRect = firstNode.getBoundingClientRect(); const leadingGap = firstRect.top - rootRect.top; if (start > 0 && leadingGap > 1) { const extraRows = Math.max(8, Math.ceil(leadingGap / STREAM_ESTIMATED_HEIGHT) + 4); const nextStart = Math.max(0, end - streamPerformance(ctx).streamMaxItems, start - extraRows); if (nextStart < start) { const range = stableStreamMountRange(ctx, nextStart, end, items.length); mountStreamRange(ctx, range.start, range.end); start = ctx.streamWindowStart; end = ctx.streamWindowEnd; firstNode = items[start] && items[start].node; firstRect = firstNode && firstNode.getBoundingClientRect(); } } const commentsRect = ctx.commentsEl.getBoundingClientRect(); if (commentsRect.bottom < rootRect.top || commentsRect.top > rootRect.bottom) return; const lastNode = items[end - 1] && items[end - 1].node; if (!firstNode || !lastNode || !firstNode.isConnected || !lastNode.isConnected) return; const lastRect = lastNode === firstNode ? firstRect : lastNode.getBoundingClientRect(); if (lastRect.bottom >= rootRect.top && firstRect.top <= rootRect.bottom) return; const length = Math.max(1, end - start); if (firstRect.top > rootRect.bottom && start > 0) { const gap = firstRect.top - rootRect.bottom; const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3); const nextStart = Math.max(0, start - extraRows); const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows); const range = stableStreamMountRange( ctx, nextStart, Math.min(items.length, nextStart + nextLength), items.length ); mountStreamRange(ctx, range.start, range.end); return; } if (lastRect.bottom < rootRect.top && end < items.length) { const gap = rootRect.top - lastRect.bottom; const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3); const nextEnd = Math.min(items.length, end + extraRows); const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows); const range = stableStreamMountRange( ctx, Math.max(0, nextEnd - nextLength), nextEnd, items.length ); mountStreamRange(ctx, range.start, range.end); } } function mountStreamPost(ctx, postNumber) { const item = ctx && ctx.streamItemMap && ctx.streamItemMap.get(+postNumber); if (!item || !item.node || !streamItemMatchesFilter(ctx, item)) return null; const items = streamLayoutItems(ctx); const layoutIndex = ctx.onlyOp ? item.layoutIndex : item.index; rebuildStreamPrefix(ctx); const span = Math.min( streamPerformance(ctx).streamMaxItems, Math.max(12, Math.ceil(ctx.scrollRoot.clientHeight / STREAM_PLACEHOLDER_MIN_HEIGHT) + 8) ); const start = Math.max(0, Math.min(layoutIndex - Math.floor(span / 2), items.length - span)); const end = Math.min(items.length, start + span); mountStreamRange(ctx, start, end); hydratePostContent(item.node, ctx); return item.node.isConnected ? item.node : null; } function getStreamPostNode(ctx, postNumber, options = {}) { const num = +postNumber; if (!ctx || !num) return null; const node = ctx.streamNodeMap && ctx.streamNodeMap.get(num); if (node && node.isConnected) return node; if (options.mount) return mountStreamPost(ctx, num); return null; } function getStreamListOffset(ctx) { if (Number.isFinite(ctx.streamListOffset)) return ctx.streamListOffset; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const commentsRect = ctx.commentsEl.getBoundingClientRect(); ctx.streamListOffset = ctx.scrollRoot.scrollTop + commentsRect.top - rootRect.top; return ctx.streamListOffset; } function stableStreamMountRange(ctx, start, end, total) { start = Math.max(0, Math.min(total, +start || 0)); end = Math.max(start, Math.min(total, +end || 0)); if (ctx.streamWindowResetPending) { ctx.streamWindowResetPending = false; return { start, end }; } const currentStart = +ctx.streamWindowStart || 0; const currentEnd = +ctx.streamWindowEnd || 0; const currentLength = currentEnd - currentStart; const currentValid = ctx.streamMountedNodes.size > 0 && currentLength > 0 && currentStart >= 0 && currentEnd <= total; if (!currentValid) return { start, end }; const maxItems = Math.max(1, streamPerformance(ctx).streamMaxItems); const anchorOwner = ctx.streamViewportAnchor && ctx.streamViewportAnchor.owner; const anchorItem = anchorOwner && anchorOwner.isConnected ? ctx.streamItemMap.get(streamPostNumber(anchorOwner)) : null; const anchorIndex = anchorItem && (ctx.onlyOp ? anchorItem.layoutIndex : anchorItem.index); const currentContainsAnchor = Number.isInteger(anchorIndex) && anchorIndex >= currentStart && anchorIndex < currentEnd; const nextContainsAnchor = Number.isInteger(anchorIndex) && anchorIndex >= start && anchorIndex < end; if (currentContainsAnchor && !nextContainsAnchor) { const span = Math.min(maxItems, Math.max(1, currentLength, end - start)); if (ctx.streamScrollDirection < 0) { const trailingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1))); end = Math.min(total, anchorIndex + trailingItems + 1); start = Math.max(0, end - span); end = Math.min(total, start + span); } else if (ctx.streamScrollDirection > 0) { const leadingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1))); start = Math.max(0, anchorIndex - leadingItems); end = Math.min(total, start + span); start = Math.max(0, end - span); } else { start = Math.max(0, anchorIndex - Math.floor(span / 2)); end = Math.min(total, start + span); start = Math.max(0, end - span); } return { start, end }; } const unionStart = Math.min(currentStart, start); const unionEnd = Math.max(currentEnd, end); if (unionEnd - unionStart <= maxItems) return { start: unionStart, end: unionEnd }; // A small overlap avoids churn while measured heights settle. Once the DOM // budget is full, slide the window instead of retaining every visited floor. if (start >= currentStart && end >= currentEnd) { return { start: Math.max(0, unionEnd - maxItems), end: unionEnd }; } if (start <= currentStart && end <= currentEnd) { return { start: unionStart, end: Math.min(total, unionStart + maxItems) }; } return { start, end }; } function syncStreamWindow(ctx) { if (!ctx || !ctx.commentsEl || !ctx.scrollRoot || !ctx.streamItems.length) return; const items = streamLayoutItems(ctx); ensureStreamScaffold(ctx); rebuildStreamPrefix(ctx); if (!items.length) { mountStreamRange(ctx, 0, 0); return; } const listOffset = getStreamListOffset(ctx); const viewportHeight = Math.max(ctx.scrollRoot.clientHeight, 1); const performance = streamPerformance(ctx); const overscan = viewportHeight * performance.streamOverscanViewports; const velocityLead = Math.min( viewportHeight * 2, Math.max(0, Number(ctx.streamScrollVelocity) || 0) * 700 ); const beforeOverscan = overscan + (ctx.streamScrollDirection < 0 ? velocityLead : 0); const afterOverscan = overscan + (ctx.streamScrollDirection > 0 ? velocityLead : 0); const localTop = Math.max(0, ctx.scrollRoot.scrollTop - listOffset); ctx.streamViewportTopIndex = findStreamIndexAtOffset(ctx.streamPrefix, localTop); ctx.streamViewportBottomIndex = findStreamIndexAtOffset( ctx.streamPrefix, Math.max(0, localTop + viewportHeight - 10) ); let start = findStreamIndexAtOffset(ctx.streamPrefix, Math.max(0, localTop - beforeOverscan)); let end = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight + afterOverscan) + 1; if (end - start > performance.streamMaxItems) { const center = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight / 2); start = Math.max(0, center - Math.floor(performance.streamMaxItems / 2)); end = Math.min(items.length, start + performance.streamMaxItems); start = Math.max(0, end - performance.streamMaxItems); } const stableRange = stableStreamMountRange( ctx, start, end, items.length ); start = stableRange.start; end = stableRange.end; const needsRepair = ctx.streamNeedsRepair || start !== ctx.streamWindowStart || end !== ctx.streamWindowEnd; mountStreamRange(ctx, start, end); ctx.streamNeedsRepair = false; if (needsRepair) { repairBlankStreamViewport(ctx, start, end); } if (ctx.viewportPersistenceEnabled && ctx.loader && typeof ctx.loader.persistViewedPostNumbers === 'function') { const visibleStart = Math.max(0, Math.min(items.length - 1, ctx.streamViewportTopIndex)); const visibleEnd = Math.max(visibleStart, Math.min(items.length - 1, ctx.streamViewportBottomIndex)); ctx.loader.persistViewedPostNumbers( items.slice(visibleStart, visibleEnd + 1).map((item) => item && item.postNumber) ); } } function scheduleStreamWindowSync(ctx) { if (!ctx || ctx.streamWindowFrame) return; ctx.streamWindowFrame = requestAnimationFrame(() => { ctx.streamWindowFrame = 0; syncStreamWindow(ctx); // Defer geometry reads until the mounted window has completed a layout. if (ctx.timeline) ctx.timeline.update(); }); } function flushStreamWindow(ctx) { if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame); ctx.streamWindowFrame = 0; syncStreamWindow(ctx); } function scheduleFloorPreviewSync(ctx) { if (!ctx || !ctx.floorPreview || ctx.floorPreviewSyncFrame) return; ctx.floorPreviewSyncFrame = requestAnimationFrame(() => { ctx.floorPreviewSyncFrame = 0; if (ctx.floorPreview && ctx.floorPreview.isOpen()) ctx.floorPreview.syncPlacement(); }); } const PINNED_STREAM_SCROLL_OPTIONS = Object.freeze({ preferStream: true, instant: true, pinStreamTarget: true, }); function scrollToPost(postNumber, ctx, options = {}) { clearStreamViewportAnchor(ctx); ctx.streamScrollDirection = 0; const programmaticTarget = options.pinStreamTarget === true ? beginStreamProgrammaticTarget(ctx, postNumber, options) : null; if (programmaticTarget) hydrateStreamNavigationNeighborhood(ctx, postNumber); let target = null; if (options.preferStream && ctx.streamNodeMap) target = getStreamPostNode(ctx, postNumber, { mount: true }); if (!target && options.requireStream) { if (programmaticTarget) clearStreamProgrammaticTarget(ctx); return false; } if (!target) target = ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`); if (!target) { if (programmaticTarget) clearStreamProgrammaticTarget(ctx); return false; } const previousScrollTop = ctx.scrollRoot.scrollTop; if (!options.alignTop && !options.alignBottom && +postNumber < +(ctx.initialTargetHydrationBoundary || 0) && typeof ctx.releaseInitialPrecedingContent === 'function') { ctx.releaseInitialPrecedingContent(); } hydratePostContent(target, ctx); const resolvedVisualTarget = typeof options.resolveVisualTarget === 'function' ? options.resolveVisualTarget(target) : options.visualNode; const scrollTarget = resolvedVisualTarget && resolvedVisualTarget.isConnected ? resolvedVisualTarget : target; const hasVisualTarget = scrollTarget !== target; const highlightTarget = options.highlightTarget && options.highlightTarget.isConnected ? options.highlightTarget : target; const alignOffset = Number(options.alignOffset); const expandedJumpTarget = target.classList.contains('ldp-nested-collapsed') && !options.keepCollapsed; if (expandedJumpTarget) { target.dataset.ldpJumpExpanded = '1'; setNestedReplyCollapsed(target, false); } if (options.alignBottom) { const rootRect = ctx.scrollRoot.getBoundingClientRect(); const targetRect = scrollTarget.getBoundingClientRect(); const desiredScrollTop = ctx.scrollRoot.scrollTop + targetRect.bottom - rootRect.bottom; const currentTopSpacerHeight = parseFloat( ctx.streamTopSpacer.style.getPropertyValue('--ldp-stream-spacer-height') ) || 0; ctx.initialTargetHeadSpacerHeight = Math.max( +(ctx.initialTargetHeadSpacerHeight || 0), currentTopSpacerHeight + Math.max(0, -desiredScrollTop) + 2 ); setStreamSpacerHeight( ctx.streamTopSpacer, Math.max(currentTopSpacerHeight, ctx.initialTargetHeadSpacerHeight) ); scrollTarget.scrollIntoView({ behavior: 'auto', block: 'end' }); const remainingOffset = scrollTarget.getBoundingClientRect().bottom - ctx.scrollRoot.getBoundingClientRect().bottom; if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset; } else if (options.alignCenter && hasVisualTarget) { scrollTarget.scrollIntoView({ behavior: 'auto', block: 'center' }); const rootRect = ctx.scrollRoot.getBoundingClientRect(); const targetRect = scrollTarget.getBoundingClientRect(); const remainingOffset = targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2); if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset; } else if (options.alignNearest && hasVisualTarget) { scrollTarget.scrollIntoView({ behavior: 'auto', block: 'nearest' }); } else if (Number.isFinite(alignOffset)) { scrollTarget.scrollIntoView({ behavior: 'auto', block: 'start' }); const rootRect = ctx.scrollRoot.getBoundingClientRect(); const remainingOffset = scrollTarget.getBoundingClientRect().top - rootRect.top - alignOffset; if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset; } else if (options.alignTop) { scrollTarget.scrollIntoView({ behavior: 'auto', block: 'start' }); const remainingOffset = scrollTarget.getBoundingClientRect().top - ctx.scrollRoot.getBoundingClientRect().top; if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset; } else { const behavior = options.instant === true ? 'auto' : (shouldSmoothScrollToPost(scrollTarget, ctx.scrollRoot) ? 'smooth' : 'auto'); const block = +postNumber === 1 ? 'start' : 'center'; scrollTarget.scrollIntoView({ behavior, block }); } markStreamInternalScroll(ctx, previousScrollTop); if (programmaticTarget) clearStreamProgrammaticTarget(ctx); if (options.highlight !== false) { if (expandedJumpTarget) { requestAnimationFrame(() => requestAnimationFrame(() => { if (highlightTarget.isConnected && !target.classList.contains('ldp-nested-collapsed')) { keepJumpHighlightUntilSettled(highlightTarget); } })); } else { keepJumpHighlightUntilSettled(highlightTarget); } } scheduleStreamWindowSync(ctx); return true; } function clearJumpBackLinks(ctx) { ctx.scrollRoot.querySelectorAll('.ldp-jump-backbar').forEach((el) => el.remove()); } function showJumpBackLink(targetPostNumber, sourcePostNumber, ctx) { const target = getStreamPostNode(ctx, targetPostNumber, { mount: true }) || ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${targetPostNumber}"]`); if (!target || !sourcePostNumber || sourcePostNumber === targetPostNumber) return; clearJumpBackLinks(ctx); const bar = document.createElement('div'); bar.className = 'ldp-jump-linkbar ldp-jump-backbar'; bar.innerHTML = `<button class="ldp-btn ldp-jump-back" type="button" data-target-post-number="${sourcePostNumber}" aria-label="返回刚才的楼层 #${sourcePostNumber}">${icon('cornerDownRight')}<span>返回 #${sourcePostNumber}</span></button>`; target.insertBefore(bar, target.firstChild); } function ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx) { const target = getStreamPostNode(ctx, targetPostNumber); const parent = +parentPostNumber; if (!target || !parent || parent === +targetPostNumber) return; target.dataset.replyToPostNumber = String(parent); ensureParentJumpControl(target, parent); } function findFloorPreviewSource(postNumber, ctx) { const num = +postNumber; if (!num) return null; // Previewing must not remount the virtual window. Detached stream nodes still // contain the complete post markup and are safe to clone into the preview. const streamNode = ctx.streamNodeMap && ctx.streamNodeMap.get(num); if (streamNode) return streamNode; return (ctx.scrollRoot && ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${num}"]`)) || null; } async function ensureFloorPreviewSource(postNumber, ctx) { const target = +postNumber; let source = findFloorPreviewSource(target, ctx); if (!target || source || !ctx) return source; const loaders = typeof ctx.loadPost === 'function' ? ['single', 'around'].map((scope) => () => ctx.loadPost(target, { mount: false, priority: POST_REQUEST_PRIORITY.target, scope, })) : []; for (const load of loaders) { try { await load(); } catch (error) { if (shouldStopReaderFallback(error)) break; } source = findFloorPreviewSource(target, ctx); if (source) return source; } return null; } function resetFloorPreview(preview) { preview.textContent = ''; const body = document.createElement('div'); body.className = 'ldp-floor-preview-body'; const close = document.createElement('button'); close.className = 'ldp-floor-preview-close'; close.type = 'button'; close.setAttribute('aria-label', '关闭预览'); close.innerHTML = `<span class="ldp-floor-preview-close-hint">Esc 关闭</span>${icon('x')}`; preview.append(body, close); return body; } function setFloorPreviewTip(preview, postNumber, text) { const body = resetFloorPreview(preview, postNumber); body.innerHTML = `<div class="ldp-floor-preview-tip">${esc(text)}</div>`; } function fillFloorPreview(preview, source, postNumber) { const body = resetFloorPreview(preview, postNumber); const wrap = document.createElement('div'); wrap.className = 'ldp-floor-preview-post'; const head = source.querySelector(':scope > .ldp-post-head')?.cloneNode(true); const content = source.querySelector(':scope > .ldp-content')?.cloneNode(true); const boosts = source.querySelector(':scope > .ldp-boost-list')?.cloneNode(true); if (head) { const readState = head.querySelector(':scope > .ldp-post-read-state'); head.querySelectorAll(':scope > .ldp-floor, :scope > .ldp-actions').forEach((el) => el.remove()); const floorBtn = document.createElement('button'); floorBtn.className = 'ldp-floor ldp-jump-preview-self'; floorBtn.type = 'button'; floorBtn.dataset.targetPostNumber = String(postNumber); floorBtn.setAttribute('aria-label', `跳到楼层 #${postNumber}`); floorBtn.textContent = `#${postNumber}`; head.appendChild(floorBtn); if (readState) head.appendChild(readState); preview.insertBefore(head, body); const close = preview.querySelector(':scope > .ldp-floor-preview-close'); if (close) head.appendChild(close); } if (content) wrap.appendChild(content); else { const empty = document.createElement('div'); empty.className = 'ldp-floor-preview-tip'; empty.textContent = `#${postNumber} 暂无可预览内容`; wrap.appendChild(empty); } if (boosts) wrap.appendChild(boosts); wrap.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); body.appendChild(wrap); } function syncFloorPreviewLine(preview, line, layer) { const lineRect = line.getBoundingClientRect(); const previewRect = preview.getBoundingClientRect(); const layerRect = layer ? layer.getBoundingClientRect() : previewRect; const avatar = layer && layer.querySelector(':scope > .ldp-post-head .ldp-avatar'); const avatarRect = avatar ? avatar.getBoundingClientRect() : layerRect; const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar'); const previewAvatarRect = previewAvatar ? previewAvatar.getBoundingClientRect() : previewRect; const previewX = Math.max(0, previewRect.left - lineRect.left); const childX = Math.max(0, avatarRect.left - lineRect.left); const childY = Math.max(0, avatarRect.top + avatarRect.height / 2 - lineRect.top); const previewY = previewAvatarRect.top + previewAvatarRect.height / 2 - lineRect.top; const baseRail = 15; const axisX = Math.min(previewX, childX) - baseRail; const branchGap = 5; const previewBranchGap = 5; const previewBranchWidth = Math.max(0, previewX - axisX - previewBranchGap); const childBranchWidth = Math.max(0, childX - axisX - branchGap); line.style.setProperty('--ldp-parent-axis-left', `${Math.round(axisX)}px`); line.style.setProperty('--ldp-parent-axis-top', `${Math.round(previewY)}px`); line.style.setProperty('--ldp-parent-preview-rail-width', `${Math.round(previewBranchWidth)}px`); line.style.setProperty('--ldp-parent-child-branch-width', `${Math.round(childBranchWidth)}px`); line.style.setProperty('--ldp-parent-child-y', `${Math.round(childY)}px`); line.style.setProperty('--ldp-parent-preview-y', `${Math.round(previewY)}px`); } function keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx = null) { if (!layer || !scrollRoot || !layer.isConnected) return; const correction = layer.getBoundingClientRect().top - anchorTop; if (Math.abs(correction) < 1) return; const previousScrollTop = scrollRoot.scrollTop; scrollRoot.scrollTop += correction; markStreamInternalScroll(ctx, previousScrollTop); } function preserveFloorPreviewAnchor(layer, scrollRoot, update, ctx = null) { if (!layer || !scrollRoot) { update(); return; } const anchorTop = layer.getBoundingClientRect().top; update(); keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx); } function placeFloorPreview(preview, line, anchor, scrollRoot = null, onFinish = null, ctx = null) { const layer = anchor.closest('.ldp-post'); if (!layer || !layer.parentElement) return; preview._ldpRelatedPost = layer; let placed = false; // Insert the complete preview and correct the anchor in the same task so the // browser never paints the intermediate shifted position. preserveFloorPreviewAnchor(layer, scrollRoot, () => { const currentParent = layer.parentElement; if (!currentParent) return; if (!placed && (preview.parentElement !== currentParent || preview.nextElementSibling !== line || line.nextElementSibling !== layer)) { moveOwnedNodeBefore(currentParent, preview, layer); moveOwnedNodeBefore(currentParent, line, layer); } placed = true; preview.classList.add('open'); line.classList.add('open'); syncFloorPreviewLine(preview, line, layer); }, ctx); if (!placed) return; syncChildControls(layer); if (onFinish) onFinish(); } function canShowFloorPreview(anchor) { const post = anchor && anchor.closest('.ldp-post'); return !!post && !post.classList.contains('ldp-nested-collapsed'); } async function showFloorPreview(anchor, ctx, state, options = {}) { const postNumber = +anchor.dataset.targetPostNumber; const allowCollapsed = options.allowCollapsed === true; if (!postNumber || (!allowCollapsed && !canShowFloorPreview(anchor))) return; clearTimeout(state.hideTimer); state.anchor = anchor; const token = ++state.token; const preview = state.preview; // Do not expose a loading shell whose later height change would move the // virtual list. Materialize the requested floor DOM off-screen first, then // insert the final preview exactly once. const source = await ensureFloorPreviewSource(postNumber, ctx); if (token !== state.token || state.anchor !== anchor || !anchor.isConnected || (!allowCollapsed && !canShowFloorPreview(anchor))) return; if (source) { hydratePostContent(source, ctx); fillFloorPreview(preview, source, postNumber); } else { setFloorPreviewTip(preview, postNumber, `还没加载到 #${postNumber}`); } const relatedPost = anchor.closest('.ldp-post'); if (typeof options.beforePlace === 'function') options.beforePlace(); if (!canShowFloorPreview(anchor)) return; setNestedReplyEscapeHint(relatedPost, isEscCollapsibleNestedReply(relatedPost)); placeFloorPreview(preview, state.line, anchor, ctx.scrollRoot, () => { alignCollapsedFloorPreview(preview, relatedPost, options.collapsedBottom, ctx); if (ctx.timeline) ctx.timeline.sync(); }, ctx); } function bindFloorPreview(modal, ctx) { const root = ctx.scrollRoot || modal; const floorPreviewBundles = new Map(); const openBundles = new Set(); const bundleByPost = new WeakMap(); let activeBundle = null; const bundlePost = (bundle) => bundle && (bundle.post || (bundle.anchor && bundle.anchor.closest('.ldp-post'))); const unregisterBundle = (bundle) => { if (!bundle) return; clearTimeout(bundle.showTimer); openBundles.delete(bundle); if (bundle.anchor) floorPreviewBundles.delete(bundle.anchor); const post = bundlePost(bundle); if (post) { bundleByPost.delete(post); } removeOwnedNode(bundle.preview); removeOwnedNode(bundle.line); if (activeBundle === bundle) activeBundle = null; }; const cleanupClosedBundles = () => { Array.from(openBundles).forEach((bundle) => { // Virtual-list unmounting temporarily detaches an open preview together // with its post. Keep that bundle so the same DOM can be mounted again. if (!bundle.pending && !bundle.preview.classList.contains('open')) unregisterBundle(bundle); }); }; const activateBundle = (bundle) => { const post = bundlePost(bundle); openBundles.add(bundle); if (post) bundleByPost.set(post, bundle); activeBundle = bundle; }; const bundleVisibleInViewport = (bundle) => { if (!bundle || !bundle.preview.classList.contains('open')) return false; const rect = bundle.preview.getBoundingClientRect(); const rootRect = (ctx.scrollRoot || modal).getBoundingClientRect(); return rect.bottom > rootRect.top && rect.top < rootRect.bottom && rect.right > rootRect.left && rect.left < rootRect.right; }; const hideBundle = (bundle) => { if (!bundle) return; const post = bundlePost(bundle); clearTimeout(bundle.hideTimer); bundle.token++; preserveFloorPreviewAnchor(post, root, () => { bundle.preview.classList.remove('open'); bundle.line.classList.remove('open'); }, ctx); if (post) syncChildControls(post); unregisterBundle(bundle); return post; }; const hide = (targetPost = null) => { cleanupClosedBundles(); const bundles = Array.from(openBundles); let bundle = targetPost ? bundles.find((item) => bundlePost(item) === targetPost) : null; if (!bundle && activeBundle && bundleVisibleInViewport(activeBundle)) bundle = activeBundle; if (!bundle) { const visibleBundles = bundles.filter(bundleVisibleInViewport); bundle = visibleBundles[visibleBundles.length - 1] || activeBundle || bundles[bundles.length - 1]; } return hideBundle(bundle); }; function createFloorPreviewBundle(anchor) { const preview = document.createElement('div'); preview.className = 'ldp-floor-preview'; const line = document.createElement('div'); line.className = 'ldp-floor-preview-link'; line.setAttribute('aria-hidden', 'true'); root.appendChild(preview); root.appendChild(line); const bundle = { preview, line, anchor, post: anchor.closest('.ldp-post'), token: 0, hideTimer: 0, showTimer: 0, hoverPending: false, pending: false, }; floorPreviewBundles.set(anchor, bundle); preview.addEventListener('click', async (e) => { if (e.target.closest('.ldp-floor-preview-close')) { hideBundle(bundle); return; } const floorBtn = e.target.closest('.ldp-jump-preview-self'); if (!floorBtn) return; e.preventDefault(); e.stopPropagation(); const targetPostNumber = +floorBtn.dataset.targetPostNumber; if (!targetPostNumber) return; floorBtn.blur(); clearJumpBackLinks(ctx); await ctx.timeline.jumpTo(targetPostNumber); }); return bundle; } const bundleForAnchor = (anchor) => floorPreviewBundles.get(anchor) || createFloorPreviewBundle(anchor); const show = async (anchor, options = {}) => { if (!anchor || (!options.allowCollapsed && !canShowFloorPreview(anchor))) return false; const bundle = bundleForAnchor(anchor); bundle.pending = true; activateBundle(bundle); try { await showFloorPreview(anchor, ctx, bundle, options); } finally { bundle.pending = false; } const opened = bundle.preview.classList.contains('open'); if (!opened) unregisterBundle(bundle); return opened; }; const previewVisibleInViewport = () => { cleanupClosedBundles(); return Array.from(openBundles).some(bundleVisibleInViewport); }; const onFloorPreviewMouseOver = (e) => { if (e.target.closest('.ldp-floor-preview')) return; const btn = e.target.closest('.ldp-jump-parent'); if (!btn || !modal.contains(btn)) return; if (btn.contains(e.relatedTarget)) return; if (!canShowFloorPreview(btn)) { const bundle = floorPreviewBundles.get(btn); if (bundle) hideBundle(bundle); return; } const bundle = bundleForAnchor(btn); if (bundle.preview.classList.contains('open')) return; clearTimeout(bundle.showTimer); bundle.hoverPending = true; bundle.showTimer = setTimeout(async () => { bundle.showTimer = 0; if (!bundle.hoverPending || !btn.isConnected || !btn.matches(':hover')) { bundle.hoverPending = false; unregisterBundle(bundle); return; } activateBundle(bundle); await showFloorPreview(btn, ctx, bundle); bundle.hoverPending = false; if (!bundle.preview.classList.contains('open')) unregisterBundle(bundle); }, 250); }; const onFloorPreviewMouseOut = (e) => { const btn = e.target.closest('.ldp-jump-parent'); if (!btn || btn.contains(e.relatedTarget)) return; const bundle = floorPreviewBundles.get(btn); if (!bundle || !bundle.hoverPending) return; clearTimeout(bundle.showTimer); bundle.showTimer = 0; bundle.hoverPending = false; if (!bundle.preview.classList.contains('open')) { bundle.token++; unregisterBundle(bundle); } }; const onFloorPreviewFocusIn = (e) => { const btn = e.target.closest('.ldp-jump-parent'); if (!btn || !canShowFloorPreview(btn)) return; const bundle = bundleForAnchor(btn); clearTimeout(bundle.showTimer); bundle.showTimer = 0; bundle.hoverPending = false; activateBundle(bundle); showFloorPreview(btn, ctx, bundle); }; modal.addEventListener('mouseover', onFloorPreviewMouseOver); modal.addEventListener('mouseout', onFloorPreviewMouseOut); modal.addEventListener('focusin', onFloorPreviewFocusIn); const getBundleForPost = (post) => { cleanupClosedBundles(); const bundle = bundleByPost.get(post); return bundle ? [bundle.preview, bundle.line, post] : [post]; }; const syncPlacement = () => { cleanupClosedBundles(); openBundles.forEach((bundle) => { const post = bundlePost(bundle); if (!post || !post.isConnected) return; if (bundle.preview.nextElementSibling !== bundle.line || bundle.line.nextElementSibling !== post) return; syncFloorPreviewLine(bundle.preview, bundle.line, post); syncChildControls(post); }); }; const destroy = () => { modal.removeEventListener('mouseover', onFloorPreviewMouseOver); modal.removeEventListener('mouseout', onFloorPreviewMouseOut); modal.removeEventListener('focusin', onFloorPreviewFocusIn); floorPreviewBundles.forEach((bundle) => { clearTimeout(bundle.showTimer); clearTimeout(bundle.hideTimer); bundle.token++; removeOwnedNode(bundle.preview); removeOwnedNode(bundle.line); }); floorPreviewBundles.clear(); openBundles.clear(); activeBundle = null; }; return { show, hide, isOpen: () => { cleanupClosedBundles(); return openBundles.size > 0; }, isVisibleInViewport: previewVisibleInViewport, getBundleForPost, syncPlacement, destroy, }; } /* ============ 3. 图片浏览器 ============ */ function absoluteImageUrl(src) { try { return new URL(String(src || ''), location.href).href; } catch (e) { return String(src || ''); } } async function cachedEmojiImageSource(rawSource) { const source = absoluteImageUrl(rawSource); if (!source || source.startsWith('data:') || source.startsWith('blob:') || typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) return ''; const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal; const cachedObjectUrl = touchResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source); if (cachedObjectUrl) return cachedObjectUrl; try { const { response } = await persistentResourceCacheEntry( LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE ); if (!response) return ''; const blob = await response.blob(); return blob.size && !resourceCacheSignal.aborted ? cacheResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 512) : ''; } catch (error) { return ''; } } function persistEmojiImage(rawSource) { const source = absoluteImageUrl(rawSource); if (!source || source.startsWith('data:') || source.startsWith('blob:') || typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) { return Promise.resolve(); } const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal; if (EMOJI_IMAGE_RESOURCE_REQUESTS.has(source)) return EMOJI_IMAGE_RESOURCE_REQUESTS.get(source); const request = (async () => { try { const { storage, response: existing } = await persistentResourceCacheEntry( LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE ); if (existing) return; const resource = await fetchReaderPublicResource( source, POST_REQUEST_PRIORITY.background, 'media', resourceCacheSignal ); if (!resource) return; try { const fetched = resource.response; if (!fetched.ok) return; const blob = await fetched.blob(); if (!blob.size || resourceCacheSignal.aborted) return; await storage.put(source, createPersistentResourceResponse(blob, fetched.headers.get('content-type'))); } finally { resource.release(); } } catch (error) {} })(); return trackSingleFlightRequest(EMOJI_IMAGE_RESOURCE_REQUESTS, source, request); } function watchPersistentEmojiImages(content) { if (!content || content._ldpStopEmojiImageCache) return; const hydrate = async (image) => { if (!(image instanceof Element) || image.tagName !== 'IMG') return; const source = absoluteImageUrl(image.currentSrc || image.src); if (!source || source.startsWith('data:') || source.startsWith('blob:')) return; image.dataset.ldpEmojiCacheSource = source; const cachedSource = await cachedEmojiImageSource(source); if (cachedSource) { if (image.isConnected && image.dataset.ldpEmojiCacheSource === source) image.src = cachedSource; return; } const persist = () => { if (image.dataset.ldpEmojiCacheSource === source) void persistEmojiImage(source); }; if (image.complete && image.naturalWidth) persist(); else image.addEventListener('load', persist, { once: true }); }; const hydrateTree = (node) => { if (!(node instanceof Element)) return; if (node.matches('img[src]')) void hydrate(node); node.querySelectorAll('img[src]').forEach((image) => { void hydrate(image); }); }; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes') hydrateTree(mutation.target); mutation.addedNodes.forEach(hydrateTree); }); }); content.querySelectorAll('.emoji-picker img[src]').forEach((image) => { void hydrate(image); }); observer.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); content._ldpStopEmojiImageCache = () => { observer.disconnect(); delete content._ldpStopEmojiImageCache; }; } async function deletePersistentLightboxImage(rawSource) { const source = absoluteImageUrl(rawSource); const objectUrl = LIGHTBOX_IMAGE_OBJECT_URL_CACHE.get(source); if (objectUrl) URL.revokeObjectURL(objectUrl); LIGHTBOX_IMAGE_OBJECT_URL_CACHE.delete(source); if (typeof caches === 'undefined') return; try { const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME); await storage.delete(source); } catch (error) {} } function comparableLightboxImageSource(value) { const source = absoluteImageUrl(value); if (!source) return ''; try { const url = new URL(source, location.href); const uploadHash = url.pathname.match(/(?:^|\/)([0-9a-f]{40})(?:\.[a-z0-9]+)?(?:$|\/)/i); if (uploadHash) return `upload:${uploadHash[1].toLowerCase()}`; return `${url.origin}${decodeURIComponent(url.pathname)}`; } catch (error) { return String(source).split(/[?#]/, 1)[0]; } } function lightboxImageIdentity(item) { if (!item) return ''; return [ Math.max(0, +(item.topicId || 0)), Math.max(0, +(item.postNumber || 0)), Math.max(0, +(item.imageOrder || 0)), comparableLightboxImageSource(item.originalSrc), ].join(':'); } function lightboxImageItem(img, postNumber = 0, imageOrder = 0, postData = null, topicId = 0) { if (!img) return null; const originalSrc = absoluteImageUrl(resolveOriginalSrc(img)); if (!originalSrc) return null; const ownerPost = img.closest('.ldp-post'); const sourcePost = postData || (ownerPost && ownerPost._ldpPostData) || null; const item = { originalSrc, thumbSrc: absoluteImageUrl(img.currentSrc || img.src || originalSrc), alt: String(img.alt || '').trim(), postNumber: Math.max(0, +postNumber || +(ownerPost && ownerPost.dataset.postNumber) || 0), imageOrder: Math.max(0, +imageOrder || 0), topicId: Math.max(0, +topicId || +(sourcePost && sourcePost.topic_id) || 0), postId: Math.max(0, +(sourcePost && sourcePost.id) || +(ownerPost && ownerPost.dataset.postId) || 0), username: String(sourcePost && sourcePost.username || ownerPost && ownerPost.dataset.username || '').trim(), sourcePost, }; item.key = lightboxImageIdentity(item); return item; } function isHostBoostEmojiImage(img) { if (!img) return false; if (img.classList.contains('emoji')) return true; if (!reactionEmojiImageSources || !reactionEmojiImageSources.size) return false; return [img.currentSrc, img.src, img.getAttribute('src'), resolveOriginalSrc(img)] .map(comparableLightboxImageSource) .some((source) => source && reactionEmojiImageSources.has(source)); } function isLightboxContentImage(img, quoted = false) { if (!img || typeof img.closest !== 'function') return false; const contentSelector = quoted ? '.ldp-content aside.ldp-post-quote > blockquote' : '.ldp-content'; if (!img.closest(contentSelector) || isHostBoostEmojiImage(img)) return false; const excludedSelector = quoted ? '.ldp-quote-title,[data-user-card],aside.onebox' : 'aside.quote,.ldp-quote-title,[data-user-card],aside.onebox'; if (img.closest(excludedSelector)) return false; const hasAvatarClass = Array.from(img.classList || []) .some((name) => /(^|[-_])avatar($|[-_])/i.test(name)); const source = String(img.currentSrc || img.src || img.getAttribute('src') || ''); return !hasAvatarClass && !/\/user_avatar\//i.test(source); } const isLightboxFloorImage = (img) => isLightboxContentImage(img); const isLightboxQuotedImage = (img) => isLightboxContentImage(img, true); function lightboxImageFromEventIn(event, selector, predicate) { const image = eventPathClosest(event, `${selector} img`); if (predicate(image)) return image; const link = eventPathClosest(event, `${selector} a.lightbox`); const linkedImage = link ? link.querySelector('img') : null; return predicate(linkedImage) ? linkedImage : null; } const lightboxImageFromEvent = (event) => lightboxImageFromEventIn(event, '.ldp-content', isLightboxFloorImage); const lightboxQuotedImageFromEvent = (event) => lightboxImageFromEventIn( event, '.ldp-content aside.ldp-post-quote > blockquote', isLightboxQuotedImage); function collectLightboxItems(clickedImg, ctx) { const items = []; const seen = new Set(); const add = (img, postNumber = 0, imageOrder = 0, postData = null) => { if (!isLightboxFloorImage(img)) return; const item = lightboxImageItem(img, postNumber, imageOrder, postData, ctx && ctx.topicId); if (!item || seen.has(item.key)) return; seen.add(item.key); items.push(item); }; const roots = ctx && Array.isArray(ctx.streamItems) ? ctx.streamItems.map((item) => item && item.node).filter(Boolean) : []; if (!roots.length && ctx && ctx.commentsEl) { roots.push(...ctx.commentsEl.querySelectorAll(':scope > .ldp-post')); } roots.forEach((root) => { const postNumber = +(root.dataset.postNumber || (root._ldpPostData && root._ldpPostData.post_number)) || 0; Array.from(root.querySelectorAll(':scope > .ldp-content img')) .filter(isLightboxFloorImage) .forEach((img, imageOrder) => add(img, postNumber, imageOrder, root._ldpPostData)); }); const clickedContent = clickedImg && clickedImg.closest('.ldp-content'); const clickedOrder = clickedContent ? Array.from(clickedContent.querySelectorAll('img')).filter(isLightboxFloorImage).indexOf(clickedImg) : 0; const clickedItem = isLightboxFloorImage(clickedImg) ? lightboxImageItem(clickedImg, 0, Math.max(0, clickedOrder), null, ctx && ctx.topicId) : null; if (clickedItem && !seen.has(clickedItem.key)) { seen.add(clickedItem.key); items.unshift(clickedItem); } items.sort(compareLightboxItems); const initialIndex = clickedItem ? Math.max(0, items.findIndex((item) => item.key === clickedItem.key)) : 0; return { items, initialIndex }; } function lightboxItemsFromPost(post, topicId = 0) { if (!post || !post.cooked) return []; const content = document.createElement('div'); content.className = 'ldp-content cooked'; setPostCookedContent(content, post.cooked); return Array.from(content.querySelectorAll('img')) .filter(isLightboxFloorImage) .map((img, imageOrder) => lightboxImageItem( img, post.post_number, imageOrder, post, post.topic_id || topicId, )); } async function openQuotedImageLightbox(clickedImg, ctx) { const quote = clickedImg && clickedImg.closest('aside.ldp-post-quote'); if (!quote || !ctx) return false; try { const sourcePost = await loadReaderQuotedPost(quote, ctx); const sourceTopicId = Math.max(0, +quote.dataset.topic || +(sourcePost && sourcePost.topic_id) || +ctx.topicId || 0); const items = lightboxItemsFromPost(sourcePost, sourceTopicId); if (!items.length) throw new Error('quoted image unavailable'); const clickedSource = comparableLightboxImageSource(resolveOriginalSrc(clickedImg)); const markedOrder = lightboxImageOrderFromAlt(clickedImg.alt); let initialIndex = items.findIndex((item) => ( comparableLightboxImageSource(item.originalSrc) === clickedSource && (markedOrder == null || +item.imageOrder === markedOrder) )); if (initialIndex < 0) { const quoteBody = quote.querySelector(':scope > blockquote'); const excerptOrder = quoteBody ? Array.from(quoteBody.querySelectorAll('img')).filter(isLightboxQuotedImage).indexOf(clickedImg) : -1; initialIndex = Math.max(0, Math.min(items.length - 1, excerptOrder)); } openLightbox(null, ctx, { items, initialIndex, fixedItems: true, comments: sourceTopicId === +ctx.topicId, }); return true; } catch (error) { showSelectionToast('引用源图片加载失败,请重试'); return false; } } function compareLightboxItems(left, right) { return (+left.postNumber || 0) - (+right.postNumber || 0) || (+left.imageOrder || 0) - (+right.imageOrder || 0); } function lightboxImageOrderMarker(imageOrder) { const bits = Math.max(0, +imageOrder || 0).toString(2).padStart(8, '0'); return `\u2063${bits.replace(/0/g, '\u200B').replace(/1/g, '\u200C')}\u2064`; } function lightboxImageOrderFromAlt(value) { const match = String(value || '').match(/\u2063([\u200B\u200C]+)\u2064/); if (!match) return null; const parsed = Number.parseInt(match[1].replace(/\u200B/g, '0').replace(/\u200C/g, '1'), 2); return Number.isFinite(parsed) ? parsed : null; } function lightboxImageQuoteRaw(item, ctx) { const sourcePost = item && item.sourcePost || {}; const username = cleanUsernameValue(item && item.username || sourcePost.username); const postNumber = Math.max(0, +(item && item.postNumber || 0)); const topicId = Math.max(0, +(item && item.topicId || ctx && ctx.topicId || 0)); const source = String(item && item.originalSrc || '').replace(/</g, '%3C').replace(/>/g, '%3E'); const alt = String(item && item.alt || '图片') .replace(/\\/g, '\\\\') .replace(/\[/g, '\\[') .replace(/\]/g, '\\]') + lightboxImageOrderMarker(item && item.imageOrder); if (!username || !postNumber || !topicId || !source) return ''; return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n\n[/quote]\n\n`; } function lightboxCommentMinimumLength() { const siteSettings = lookupDiscourse('service:site-settings'); return Math.max(1, +(siteSettings && siteSettings.min_post_length) || 16); } function lightboxQuoteContainsImage(quote, item, expectedSource) { return Array.from(quote.querySelectorAll(':scope > blockquote img')).some((image) => { if (comparableLightboxImageSource(resolveOriginalSrc(image)) !== expectedSource) return false; const markedOrder = lightboxImageOrderFromAlt(image.alt); return markedOrder == null || markedOrder === +item.imageOrder; }); } function postQuotesLightboxImage(post, item) { if (!post || !item) return false; if (Array.isArray(post._ldpImageReferenceKeys) && post._ldpImageReferenceKeys.includes(item.key)) return true; if (!post.cooked || !item.postNumber) return false; const template = document.createElement('template'); template.innerHTML = String(post.cooked || ''); const expectedTopicId = +(item.topicId || 0); const expectedSource = comparableLightboxImageSource(item.originalSrc); return Array.from(template.content.querySelectorAll('aside.quote')).some((quote) => { if (+quote.dataset.post !== +item.postNumber) return false; const quotedTopicId = +(quote.dataset.topic || 0); if (quotedTopicId && expectedTopicId && quotedTopicId !== expectedTopicId) return false; return lightboxQuoteContainsImage(quote, item, expectedSource); }); } function lightboxSourceDescription(post, item) { const clean = (value) => { const text = String(value || '') .replace(/https?:\/\/\S+/gi, ' ') .replace(/\b[^\s/\\]+\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)\b/gi, ' ') .replace(/\b[0-9a-f]{20,}\b/gi, ' ') .replace(/\b\d{2,5}\s*[x×]\s*\d{2,5}\b/gi, ' ') .replace(/[x×]\s*\d{2,5}\b/gi, ' ') .replace(/\b\d+(?:\.\d+)?\s*(?:bytes?|[kmgt]i?b)\b/gi, ' ') .replace(/\s+/g, ' ') .replace(/^[\s·•|,,;;::/_-]+|[\s·•|,,;;::/_-]+$/g, '') .trim(); return text; }; const altFallback = clean(item && item.alt); const fallback = !altFallback || /^(?:该楼层)?图片$/.test(altFallback) ? '无描述' : altFallback; if (!post || !post.cooked) return fallback; const template = document.createElement('template'); template.innerHTML = String(post.cooked || ''); template.content.querySelectorAll( 'aside.quote,img,video,audio,iframe,canvas,svg,.quote-controls,.lightbox-wrapper .meta,a.lightbox .meta' ).forEach((node) => node.remove()); const text = clean(template.content.textContent); if (!text) return fallback; return text.length > 420 ? `${text.slice(0, 420).trim()}…` : text; } function normalizeLightboxDescriptionHeight(value) { const numeric = Math.round(Number(value)); const maximum = Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Math.floor(window.innerHeight * 0.4)); return Math.min( maximum, Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT) ); } function normalizeLightboxCommentsWidth(value) { const numeric = Number(value); return Math.min( LIGHTBOX_COMMENTS_WIDTH_MAX, Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_COMMENTS_WIDTH_DEFAULT) ); } function safeDownloadFilename(value, fallback = 'download') { const cleaned = String(value || '') .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') .replace(/[.\s]+$/g, '') .trim(); return (cleaned || fallback).slice(0, 120); } function lightboxDownloadTimestamp(date = new Date()) { const part = (value) => String(value).padStart(2, '0'); return `${date.getFullYear()}年${part(date.getMonth() + 1)}月${part(date.getDate())}日${part(date.getHours())}时${part(date.getMinutes())}分${part(date.getSeconds())}秒`; } function lightboxImageExtension(item, blob) { const mimeExtensions = { 'image/avif': 'avif', 'image/bmp': 'bmp', 'image/gif': 'gif', 'image/heic': 'heic', 'image/heif': 'heif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/svg+xml': 'svg', 'image/tiff': 'tiff', 'image/webp': 'webp', }; const mimeExtension = mimeExtensions[String(blob && blob.type || '').toLowerCase()]; if (mimeExtension) return mimeExtension; try { const match = new URL(item && item.originalSrc || '', location.href).pathname.match(/\.([a-z0-9]{2,5})$/i); if (match) return match[1].toLowerCase(); } catch (error) {} return 'img'; } function lightboxImageDownloadName(item, index, blob) { let sourceName = ''; try { sourceName = decodeURIComponent(new URL(item && item.originalSrc || '', location.href).pathname.split('/').pop() || ''); } catch (error) {} const extension = lightboxImageExtension(item, blob); const stem = safeDownloadFilename(sourceName.replace(/\.[a-z0-9]{2,5}$/i, ''), `image-${index + 1}`); return `${String(index + 1).padStart(3, '0')}-${stem}.${extension}`; } function triggerBrowserDownload(blob, filename) { const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = safeDownloadFilename(filename); link.hidden = true; appendReaderPortalNode(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 60 * 1000); } async function cachedPersistentLightboxBlob(rawSource) { const source = absoluteImageUrl(rawSource); if (!source) return null; if (typeof caches !== 'undefined') { try { const { response } = await persistentResourceCacheEntry( LDP_LIGHTBOX_IMAGE_CACHE_NAME, source, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE ); if (response) { const blob = await response.blob(); if (blob.size) return blob; } } catch (error) {} } const objectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source); if (!objectUrl) return null; try { const response = await fetch(objectUrl); const blob = await response.blob(); return blob.size ? blob : null; } catch (error) { return null; } } function cachedLightboxOriginalBlob(item) { return cachedPersistentLightboxBlob(item && item.originalSrc); } async function persistLightboxBlob(rawSource, blob, contentType = '', resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal) { const source = absoluteImageUrl(rawSource); if (!/^https?:/i.test(source) || !blob || !blob.size || typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING || resourceCacheSignal.aborted) return; try { void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE); const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME); if (!resourceCacheSignal.aborted) { await storage.put(source, createPersistentResourceResponse(blob, contentType)); } } catch (error) {} } async function fetchLightboxDownloadBlob(source, priority = POST_REQUEST_PRIORITY.auxiliary, options = {}) { if (!source) throw new Error('图片获取失败'); const normalizedSource = absoluteImageUrl(source); const persistentSource = /^https?:/i.test(normalizedSource) ? normalizedSource : ''; const request = LIGHTBOX_IMAGE_RESOURCE_REQUESTS.get(persistentSource) || (async () => { const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal; const localSource = normalizedSource.startsWith('blob:') || normalizedSource.startsWith('data:'); const abortWithCacheClear = options.abortWithCacheClear === true && !RESOURCE_CACHE_CLEARING; if (persistentSource) { if (abortWithCacheClear) { void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE); } const cached = await cachedPersistentLightboxBlob(persistentSource); if (cached) return cached; } let resource = null; let response; try { if (localSource) { response = await fetch(normalizedSource, { credentials: 'omit', cache: 'force-cache', [REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type: 'media' }, }); } else { resource = await fetchReaderPublicResource( normalizedSource, priority, 'media', abortWithCacheClear ? resourceCacheSignal : undefined ); if (!resource) throw new Error('图片请求已暂停'); response = resource.response; } if (!response.ok) throw new Error(`图片获取失败(${response.status})`); const blob = await response.blob(); if (!blob.size) throw new Error('图片内容为空'); if (persistentSource) { await persistLightboxBlob(persistentSource, blob, response.headers.get('content-type'), resourceCacheSignal); } return blob; } finally { if (resource) resource.release(); } })(); const tracked = persistentSource && !LIGHTBOX_IMAGE_RESOURCE_REQUESTS.has(persistentSource) ? trackSingleFlightRequest(LIGHTBOX_IMAGE_RESOURCE_REQUESTS, persistentSource, request) : request; try { return await tracked; } catch (error) { if (options.abortWithCacheClear !== true && error && error.name === 'AbortError' && RESOURCE_CACHE_CLEARING) { return fetchLightboxDownloadBlob(source, priority, options); } throw error; } } async function persistentLightboxImageSource(rawSource, options = {}) { const source = absoluteImageUrl(rawSource); if (!source || source.startsWith('data:') || source.startsWith('blob:') || RESOURCE_CACHE_CLEARING) return source; if (typeof caches === 'undefined') return options.cachedOnly ? '' : source; const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal; const cachedObjectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source); if (cachedObjectUrl) return cachedObjectUrl; try { const blob = options.cachedOnly ? await cachedPersistentLightboxBlob(source) : await fetchLightboxDownloadBlob(source, POST_REQUEST_PRIORITY.auxiliary, { abortWithCacheClear: true }); return blob && !resourceCacheSignal.aborted ? cacheResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 32) : options.cachedOnly ? '' : source; } catch (error) { return error && error.message === '图片请求已暂停' ? '' : source; } } async function lightboxImageDownloadBlob(item, options = {}) { const cachedOriginal = await cachedLightboxOriginalBlob(item); if (cachedOriginal) return cachedOriginal; const originalSrc = item && item.originalSrc; const previewSrc = item && (item.thumbSrc || originalSrc); if (options.original !== true && previewSrc && previewSrc !== originalSrc) { return fetchLightboxDownloadBlob(previewSrc); } return fetchLightboxDownloadBlob(originalSrc); } async function downloadLightboxItem(item, index) { try { const missingOriginals = await lightboxMissingOriginalCount([item]); const useOriginal = missingOriginals > 0 && globalThis.confirm( '当前原图尚未缓存。\n\n确定:按阅读器限速获取并下载原图\n取消:下载当前最高缓存/预览质量' ); const blob = await lightboxImageDownloadBlob(item, { original: useOriginal }); triggerBrowserDownload(blob, lightboxImageDownloadName(item, index, blob).replace(/^\d+-/, '')); } catch (error) { showSelectionToast(`图片下载失败:${error.message || '请重试'}`); } } async function lightboxMissingOriginalCount(items) { const candidates = (Array.isArray(items) ? items : []).filter((item) => ( item && item.originalSrc && item.thumbSrc && item.originalSrc !== item.thumbSrc )); let missing = 0; for (const item of candidates) { if (!await cachedLightboxOriginalBlob(item)) missing++; } return missing; } function lightboxZipCrc32(bytes) { if (!lightboxZipCrc32.table) { lightboxZipCrc32.table = new Uint32Array(256); for (let index = 0; index < 256; index++) { let value = index; for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1; lightboxZipCrc32.table[index] = value >>> 0; } } let crc = 0xffffffff; for (let index = 0; index < bytes.length; index++) crc = lightboxZipCrc32.table[(crc ^ bytes[index]) & 0xff] ^ crc >>> 8; return (crc ^ 0xffffffff) >>> 0; } function createStoredZip(entries) { const encoder = new globalThis.TextEncoder(); const localParts = []; const centralParts = []; let localOffset = 0; const modifiedAt = new Date(); const dosTime = modifiedAt.getHours() << 11 | modifiedAt.getMinutes() << 5 | modifiedAt.getSeconds() >> 1; const dosDate = Math.max(0, modifiedAt.getFullYear() - 1980) << 9 | modifiedAt.getMonth() + 1 << 5 | modifiedAt.getDate(); entries.forEach((entry) => { const name = encoder.encode(entry.name); const bytes = entry.bytes; const crc = lightboxZipCrc32(bytes); const local = new Uint8Array(30 + name.length); const localView = new DataView(local.buffer); localView.setUint32(0, 0x04034b50, true); localView.setUint16(4, 20, true); localView.setUint16(6, 0x0800, true); localView.setUint16(8, 0, true); localView.setUint16(10, dosTime, true); localView.setUint16(12, dosDate, true); localView.setUint32(14, crc, true); localView.setUint32(18, bytes.length, true); localView.setUint32(22, bytes.length, true); localView.setUint16(26, name.length, true); local.set(name, 30); const central = new Uint8Array(46 + name.length); const centralView = new DataView(central.buffer); centralView.setUint32(0, 0x02014b50, true); centralView.setUint16(4, 20, true); centralView.setUint16(6, 20, true); centralView.setUint16(8, 0x0800, true); centralView.setUint16(10, 0, true); centralView.setUint16(12, dosTime, true); centralView.setUint16(14, dosDate, true); centralView.setUint32(16, crc, true); centralView.setUint32(20, bytes.length, true); centralView.setUint32(24, bytes.length, true); centralView.setUint16(28, name.length, true); centralView.setUint32(42, localOffset, true); central.set(name, 46); localParts.push(local, bytes); centralParts.push(central); localOffset += local.length + bytes.length; }); const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); const end = new Uint8Array(22); const endView = new DataView(end.buffer); endView.setUint32(0, 0x06054b50, true); endView.setUint16(8, entries.length, true); endView.setUint16(10, entries.length, true); endView.setUint32(12, centralSize, true); endView.setUint32(16, localOffset, true); return new globalThis.Blob([...localParts, ...centralParts, end], { type: 'application/zip' }); } function mergeLightboxPosts(target, posts) { (Array.isArray(posts) ? posts : []).forEach((post) => { const postNumber = +(post && post.post_number || 0); if (!postNumber || !post.id) return; const previous = target.get(postNumber); target.set(postNumber, previous ? Object.assign(previous, post) : post); }); return target; } function knownLightboxPosts(ctx) { const posts = new Map(); mergeLightboxPosts(posts, ctx && ctx.loader && ctx.loader.cachedPosts); mergeLightboxPosts(posts, postsFromPayload(ctx && ctx.topicData)); if (ctx && Array.isArray(ctx.streamItems)) { mergeLightboxPosts(posts, ctx.streamItems.map((item) => item && item.node && item.node._ldpPostData)); } if (ctx && ctx.directRepliesByParent) { ctx.directRepliesByParent.forEach((replies) => mergeLightboxPosts(posts, Array.from(replies.values()))); } if (ctx && ctx.subReplyState) { ctx.subReplyState.forEach((state) => mergeLightboxPosts(posts, state && state.all)); } if (ctx && ctx.lightboxImageCommentPosts) mergeLightboxPosts(posts, Array.from(ctx.lightboxImageCommentPosts.values())); return posts; } async function searchLightboxImagePosts(ctx) { if (!ctx || !ctx.topicId || !ctx.loader || typeof ctx.loader.loadPostsByIds !== 'function') return []; if (ctx.lightboxImageSearchPostsPromise) return ctx.lightboxImageSearchPostsPromise; const request = (async () => { const posts = new Map(); const seenPages = new Set(); let page = 1; while (page <= 50) { const url = new URL(`${BASE}/search.json`); url.searchParams.set('q', `topic:${ctx.topicId} in:all-posts with:images`); url.searchParams.set('page', String(page)); const payload = await fetchJSON(url.toString(), { scheduler: ctx.postRequestScheduler, fetchOptions: { credentials: 'include', cache: 'no-store' }, cacheMode: 'refresh', key: `image-comments:search:${ctx.topicId}:${page}`, priority: POST_REQUEST_PRIORITY.background, }); const grouped = payload && payload.grouped_search_result || {}; const payloadPosts = postsFromPayload(payload); mergeLightboxPosts(posts, payloadPosts); const postIds = Array.from(new Set([ ...(Array.isArray(grouped.post_ids) ? grouped.post_ids : []), ...payloadPosts.map((post) => post && post.id), ].map(Number).filter(Boolean))); const signature = postIds.join(','); if (!postIds.length || seenPages.has(signature)) break; seenPages.add(signature); const loaded = await ctx.loader.loadPostsByIds(postIds, { priority: POST_REQUEST_PRIORITY.background, persistFetchedPosts: true, }); mergeLightboxPosts(posts, loaded); const hasMore = grouped.more_posts === true || grouped.more_full_page_results === true || payload.more_posts === true; if (!hasMore) break; page++; } const result = Array.from(posts.values()); ctx.loader.persistFetchedPosts(result); return result; })(); ctx.lightboxImageSearchPostsPromise = request.catch((error) => { ctx.lightboxImageSearchPostsPromise = null; throw error; }); return ctx.lightboxImageSearchPostsPromise; } function buildLightboxCommentTree(postsByNumber, directMatchNumbers) { const childrenByParent = new Map(); postsByNumber.forEach((post) => { const parentNumber = +(post.reply_to_post_number || 0); if (!parentNumber || !postsByNumber.has(parentNumber)) return; if (!childrenByParent.has(parentNumber)) childrenByParent.set(parentNumber, []); childrenByParent.get(parentNumber).push(post); }); childrenByParent.forEach((children) => children.sort((left, right) => +left.post_number - +right.post_number)); const roots = Array.from(directMatchNumbers) .map((postNumber) => postsByNumber.get(postNumber)) .filter((post) => post && !postsByNumber.has(+(post.reply_to_post_number || 0))) .sort((left, right) => +left.post_number - +right.post_number); return { roots, childrenByParent }; } function rememberLightboxSourcePost(item, sourcePost) { if (!sourcePost) return; item.sourcePost = sourcePost; item.postId = +(sourcePost.id || item.postId || 0); item.username = String(sourcePost.username || item.username || ''); } function lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, partial) { const { roots, childrenByParent } = buildLightboxCommentTree(postsByNumber, directMatchNumbers); return { sourcePost, postsByNumber, directMatchNumbers, roots, childrenByParent, partial: partial === true, }; } function cachedLightboxCommentRelation(item, ctx, knownPosts) { const entry = ctx && TOPIC_DATA_CACHE.get(String(ctx.topicId)); const topic = entry && entry.topic || ctx && ctx.topicData; const relations = topic && topic._ldpLightboxCommentRelations; const relation = relations && relations[item && item.key]; if (!relation || Date.now() - Number(relation.cachedAt || 0) > LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR) return null; const postsByNumber = new Map(); mergeLightboxPosts(postsByNumber, relation.posts); if (knownPosts) { for (const postNumber of postsByNumber.keys()) { const fresh = knownPosts.get(postNumber); if (fresh) postsByNumber.set(postNumber, fresh); } } const directMatchNumbers = new Set((relation.directMatchNumbers || []).map(Number).filter((value) => value > 0)); if (directMatchNumbers.size && !Array.from(directMatchNumbers).every((postNumber) => postsByNumber.has(postNumber))) return null; const sourcePostNumber = +(relation.sourcePostNumber || item && item.postNumber || 0); const sourcePost = knownPosts && knownPosts.get(sourcePostNumber) || relation.sourcePost || null; rememberLightboxSourcePost(item, sourcePost); return lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, relation.partial); } function persistLightboxCommentRelation(item, ctx, state) { if (!item || !item.key || !ctx || !ctx.topicId || !state) return; const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId)); const topic = entry && entry.topic || ctx.topicData; if (!topic) return; const relations = topic._ldpLightboxCommentRelations && typeof topic._ldpLightboxCommentRelations === 'object' ? topic._ldpLightboxCommentRelations : {}; relations[item.key] = { cachedAt: Date.now(), sourcePostNumber: +(state.sourcePost && state.sourcePost.post_number || item.postNumber || 0), sourcePost: state.sourcePost || item.sourcePost || null, directMatchNumbers: Array.from(state.directMatchNumbers || []), posts: Array.from(state.postsByNumber && state.postsByNumber.values() || []), partial: state.partial === true, }; const keys = Object.keys(relations).sort((left, right) => ( Number(relations[right] && relations[right].cachedAt || 0) - Number(relations[left] && relations[left].cachedAt || 0) )); keys.slice(64).forEach((key) => delete relations[key]); topic._ldpLightboxCommentRelations = relations; setCachedTopicData(ctx.topicId, topic); } async function loadLightboxCommentTree(item, ctx) { const knownPosts = knownLightboxPosts(ctx); const cachedRelation = cachedLightboxCommentRelation(item, ctx, knownPosts); if (cachedRelation) return cachedRelation; let sourcePost = item && item.sourcePost || knownPosts.get(+(item && item.postNumber || 0)) || null; if (!sourcePost && ctx && ctx.loader && typeof ctx.loader.loadPost === 'function') { const loaded = await ctx.loader.loadPost(item.postNumber, { advanceCursor: false, priority: POST_REQUEST_PRIORITY.visible, }); sourcePost = loaded.find((post) => +post.post_number === +item.postNumber) || null; mergeLightboxPosts(knownPosts, loaded); } rememberLightboxSourcePost(item, sourcePost); let partial = false; const [sourceRepliesResult, searchResult] = await Promise.allSettled([ sourcePost ? ctx.repliesIO.loadDirectReplies(sourcePost, { force: true }) : Promise.resolve([]), searchLightboxImagePosts(ctx), ]); if (sourceRepliesResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, sourceRepliesResult.value); else partial = true; if (searchResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, searchResult.value); else partial = true; const directMatchNumbers = new Set(); knownPosts.forEach((post, postNumber) => { if (postQuotesLightboxImage(post, item)) directMatchNumbers.add(postNumber); }); const included = new Map(); directMatchNumbers.forEach((postNumber) => { const post = knownPosts.get(postNumber); if (post) included.set(postNumber, post); }); const queue = Array.from(included.values()).sort((left, right) => +left.post_number - +right.post_number); const fetchedPostIds = new Set(); const includeReplies = (replies) => { replies.forEach((reply) => { const replyNumber = +(reply && reply.post_number || 0); if (!replyNumber) return; if (postQuotesLightboxImage(reply, item)) directMatchNumbers.add(replyNumber); if (!included.has(replyNumber)) { included.set(replyNumber, reply); queue.push(reply); } }); }; while (queue.length) { const parent = queue.shift(); const postId = +(parent && parent.id || 0); if (!postId || fetchedPostIds.has(postId)) continue; fetchedPostIds.add(postId); const parentNumber = +(parent.post_number || 0); const localChildren = Array.from(knownPosts.values()).filter((post) => ( +(post && post.reply_to_post_number || 0) === parentNumber )); includeReplies(localChildren); const knownChildren = ctx.directRepliesByParent && ctx.directRepliesByParent.get(parentNumber); if (!(+(parent.reply_count || 0) > localChildren.length || knownChildren && knownChildren.size > localChildren.length)) continue; try { const replies = await ctx.repliesIO.loadDirectReplies(parent); includeReplies(replies); } catch (error) { partial = true; } } const state = lightboxCommentTreeState(sourcePost, included, directMatchNumbers, partial); persistLightboxCommentRelation(item, ctx, state); return state; } function openLightbox(clickedImg, ctx, options = {}) { const providedItems = Array.isArray(options.items) ? options.items.filter(Boolean) : null; const imageSet = providedItems ? { items: providedItems.slice(), initialIndex: Math.max(0, Math.min(providedItems.length - 1, +options.initialIndex || 0)), } : collectLightboxItems(clickedImg, ctx); const { items, initialIndex } = imageSet; if (!items.length) return; const minimumCommentLength = lightboxCommentMinimumLength(); const lb = document.createElement('div'); lb.className = 'ldp-lightbox'; lb.setAttribute('role', 'dialog'); lb.setAttribute('aria-modal', 'true'); lb.setAttribute('aria-label', '图片预览'); lb.innerHTML = ` <div class="ldp-lb-toolbar"> <span class="ldp-lb-count"></span> <div class="ldp-lb-tools" role="toolbar" aria-label="图片工具"> <button class="ldp-lb-btn" type="button" data-lb-action="zoom-out" aria-label="缩小(-)">${icon('minus')}</button> <button class="ldp-lb-btn ldp-lb-zoom-value" type="button" data-lb-action="reset" aria-label="适应窗口(0)">100%</button> <button class="ldp-lb-btn" type="button" data-lb-action="zoom-in" aria-label="放大(+)">${icon('plus')}</button> <button class="ldp-lb-btn" type="button" data-lb-action="reset" aria-label="适应窗口(0)">${icon('rotateCcw')}</button> <button class="ldp-lb-btn" type="button" data-lb-action="view-original" aria-label="查看原图">${icon('maximize2')}</button> <button class="ldp-lb-btn" type="button" data-lb-action="download" aria-label="下载当前图片">${icon('download')}</button> <button class="ldp-lb-btn" type="button" data-lb-action="batch-download" aria-label="批量下载图片">${icon('listChecks')}</button> <button class="ldp-lb-btn" type="button" data-lb-action="jump-to-post" aria-label="跳到楼层">${icon('arrowUp')}</button> </div> <button class="ldp-lb-btn ldp-lb-comments-toggle" type="button" data-lb-action="toggle-comments" aria-label="展开图片评论" aria-expanded="false">${icon('messageSquare')}<span class="ldp-lb-comments-count">0</span></button> <button class="ldp-lb-btn ldp-lb-close" type="button" data-lb-action="close" aria-label="关闭图片预览(Esc)">${icon('x')}</button> </div> <div class="ldp-lb-main"> <button class="ldp-lb-nav ldp-lb-prev" type="button" aria-label="上一张(←)">${icon('chevronRight')}</button> <div class="ldp-lb-stage"> <div class="ldp-lb-canvas"><img class="ldp-lb-image" alt="" draggable="false" hidden></div> <div class="ldp-lb-status" role="status" aria-live="polite"><span>正在加载预览…</span><button class="ldp-lb-retry" type="button" hidden>重试</button></div> </div> <button class="ldp-lb-nav ldp-lb-next" type="button" aria-label="下一张(→)">${icon('chevronRight')}</button> <aside class="ldp-lb-comments" aria-label="图片评论"> <button class="ldp-lb-comments-resizer" type="button" role="separator" aria-orientation="vertical" aria-label="调整图片评论区宽度"></button> <div class="ldp-lb-comments-inner"> <div class="ldp-lb-comments-head"><strong>评论</strong><span>(0)</span><button class="ldp-lb-description-toggle" type="button" aria-label="展开图片描述" aria-expanded="false">${icon('chevronRight')}</button></div> <details class="ldp-lb-source"${PREFS.lightboxDescriptionExpanded === true ? ' open' : ''} hidden> <summary>图片描述</summary> <div class="ldp-lb-source-text"></div> </details> <div class="ldp-lb-source-reactions" hidden><div class="ldp-reactions"></div></div> <div class="ldp-lb-comments-body"> <div class="ldp-lb-comments-status" role="status" aria-live="polite">正在查找这张图片的评论…</div> <div class="ldp-lb-comments-empty" hidden><span>还没有人评论这张图片</span><button class="ldp-lb-add" type="button">添加第一个评论</button></div> <div class="ldp-lb-comment-list"></div> </div> <form class="ldp-lb-comment-form" hidden> <div class="ldp-lb-comment-target"></div> <textarea class="ldp-lb-comment-input" maxlength="32000" placeholder="写下你的评论(至少 ${minimumCommentLength} 个字符)…" required></textarea> <label class="ldp-lb-comment-image-option"><input type="checkbox">同时引用当前图片</label> <div class="ldp-lb-comment-error" role="alert"></div> <div class="ldp-lb-comment-actions"><button class="ldp-lb-comment-cancel" type="button">取消</button><button class="ldp-lb-comment-submit" type="submit">发送</button></div> </form> </div> </aside> </div> <div class="ldp-lb-filmstrip"${items.length > 1 ? '' : ' hidden'}> <div class="ldp-lb-strip-progress" aria-hidden="true"><span></span></div> <div class="ldp-lb-thumbs" role="listbox" aria-label="图片缩略图"> ${items.map((item, index) => `<button class="ldp-lb-thumb" type="button" role="option" data-lb-index="${index}" aria-label="${escAttr(item.alt || `查看第 ${index + 1} 张图片`)}"><img src="${escAttr(item.thumbSrc)}" alt="" decoding="async"></button>`).join('')} </div> </div> <div class="ldp-lb-batch-overlay" hidden> <section class="ldp-lb-batch-dialog" role="dialog" aria-modal="true" aria-label="批量下载图片"> <div class="ldp-lb-batch-head"><strong>批量下载</strong><button class="ldp-lb-btn ldp-lb-batch-close" type="button" aria-label="关闭批量下载">${icon('x')}</button></div> <label class="ldp-lb-batch-name" hidden><span>名称</span><input type="text" maxlength="120" aria-label="ZIP 文件名称"></label> <div class="ldp-lb-batch-tools"> <div class="ldp-lb-batch-scope" role="tablist" aria-label="批量下载范围"> <button type="button" role="tab" data-lb-batch-scope="loaded" aria-pressed="true" aria-selected="true">当前加载的图片</button> <button type="button" role="tab" data-lb-batch-scope="all" aria-pressed="false" aria-selected="false" tabindex="-1">全部帖子的图片</button> </div> <button class="ldp-lb-batch-select-all" type="button" aria-pressed="false">${icon('square')}<span>全选</span></button> <span class="ldp-lb-batch-count">已选 0 / 0</span> </div> <div class="ldp-lb-batch-grid"></div> <div class="ldp-lb-batch-progress" hidden> <div class="ldp-lb-batch-progress-copy"><span>准备下载…</span><span>0%</span></div> <div class="ldp-lb-batch-progress-track" role="progressbar" aria-label="批量下载进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="ldp-lb-batch-progress-fill"></span></div> </div> <div class="ldp-lb-batch-actions"><span class="ldp-lb-batch-status"></span><button class="ldp-lb-batch-cancel" type="button">取消</button><button class="ldp-lb-batch-download" type="button" disabled>打包下载</button></div> </section> </div>`; const stage = lb.querySelector('.ldp-lb-stage'); const mainImg = lb.querySelector('.ldp-lb-image'); const status = lb.querySelector('.ldp-lb-status'); const statusText = status.querySelector('span'); const retryBtn = status.querySelector('.ldp-lb-retry'); const countEl = lb.querySelector('.ldp-lb-count'); const zoomValue = lb.querySelector('.ldp-lb-zoom-value'); const viewOriginalBtn = lb.querySelector('[data-lb-action="view-original"]'); const jumpToPostBtn = lb.querySelector('[data-lb-action="jump-to-post"]'); const commentsToggle = lb.querySelector('.ldp-lb-comments-toggle'); const commentsPanel = lb.querySelector('.ldp-lb-comments'); const commentsResizer = lb.querySelector('.ldp-lb-comments-resizer'); const commentsToggleCount = commentsToggle.querySelector('.ldp-lb-comments-count'); const commentsHeadingCount = lb.querySelector('.ldp-lb-comments-head span'); const commentsDescriptionToggle = lb.querySelector('.ldp-lb-description-toggle'); const commentsSource = lb.querySelector('.ldp-lb-source'); const commentsSourceText = commentsSource.querySelector('.ldp-lb-source-text'); const commentsSourceReactions = lb.querySelector('.ldp-lb-source-reactions'); const commentsStatus = lb.querySelector('.ldp-lb-comments-status'); const commentsEmpty = lb.querySelector('.ldp-lb-comments-empty'); const commentsList = lb.querySelector('.ldp-lb-comment-list'); const commentForm = lb.querySelector('.ldp-lb-comment-form'); const commentTargetLabel = commentForm.querySelector('.ldp-lb-comment-target'); const commentInput = commentForm.querySelector('.ldp-lb-comment-input'); const commentImageOption = commentForm.querySelector('.ldp-lb-comment-image-option'); const commentImageCheckbox = commentImageOption.querySelector('input'); const commentError = commentForm.querySelector('.ldp-lb-comment-error'); const prevBtn = lb.querySelector('.ldp-lb-prev'); const nextBtn = lb.querySelector('.ldp-lb-next'); const filmstrip = lb.querySelector('.ldp-lb-filmstrip'); const thumbsEl = lb.querySelector('.ldp-lb-thumbs'); let thumbs = Array.from(lb.querySelectorAll('.ldp-lb-thumb')); const progress = lb.querySelector('.ldp-lb-strip-progress > span'); const batchOverlay = lb.querySelector('.ldp-lb-batch-overlay'); const batchCloseButtons = lb.querySelectorAll('.ldp-lb-batch-close,.ldp-lb-batch-cancel'); const batchNameInput = lb.querySelector('.ldp-lb-batch-name input'); const batchScopeButtons = Array.from(lb.querySelectorAll('[data-lb-batch-scope]')); const batchSelectAll = lb.querySelector('.ldp-lb-batch-select-all'); const batchCount = lb.querySelector('.ldp-lb-batch-count'); const batchGrid = lb.querySelector('.ldp-lb-batch-grid'); const batchProgress = lb.querySelector('.ldp-lb-batch-progress'); const batchProgressCopy = lb.querySelector('.ldp-lb-batch-progress-copy'); const batchProgressTrack = lb.querySelector('.ldp-lb-batch-progress-track'); const batchStatus = lb.querySelector('.ldp-lb-batch-status'); const batchDownloadButton = lb.querySelector('.ldp-lb-batch-download'); const batchSelection = new Set(); const batchThumbnailUrls = new Map(); const batchThumbnailQueue = []; let batchThumbnailActive = 0; let batchThumbnailObserver = null; let batchBusy = false; let batchScope = 'loaded'; let batchLoadedScopeKeys = new Set(); let batchLoadedForwardPostNumber = 0; let batchLoadedScopeExhausted = false; let batchAllImagesLoaded = false; const commentsEnabled = options.comments !== false; const fixedItems = options.fixedItems === true; commentsToggle.hidden = !commentsEnabled; commentsPanel.hidden = !commentsEnabled; const seenImageKeys = new Set(items.map((item) => item.key)); const commentCache = new Map(); let currentIndex = initialIndex; let mainImageRequestToken = 0; let commentRequestToken = 0; let commentTargetPost = null; let commentsManuallyToggled = false; const initialPostNumber = +(items[initialIndex] && items[initialIndex].postNumber) || 0; let exploringPrevious = false; let exploringNext = false; let jumpingToPost = false; let backwardScanPostNumber = initialPostNumber; let forwardScanPostNumber = initialPostNumber; let backwardExhausted = fixedItems || !initialPostNumber || !(ctx && ctx.loader && typeof ctx.loader.loadBeforePost === 'function') || initialPostNumber <= 1; let forwardExhausted = fixedItems || !initialPostNumber || !(ctx && ctx.loader && typeof ctx.loader.loadAfterPost === 'function'); let lightboxClosed = false; const commentsExpandedByDefault = commentsEnabled && PREFS.lightboxCommentsExpandedByDefault === true; lb.classList.toggle('ldp-lb-comments-collapsed', !commentsExpandedByDefault); commentsToggle.setAttribute('aria-expanded', String(commentsExpandedByDefault)); commentsToggle.setAttribute('aria-label', commentsExpandedByDefault ? '收起图片评论' : '展开图片评论'); lb.style.setProperty('--ldp-lb-description-height', `${normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)}px`); let commentsWidthPercent = normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent); const imageTransform = createImageTransformController(stage, mainImg, { overflowPadding: 24, allowContainedPan: true, resetPanAtFit: false, zoomValue, zoomOutButton: lb.querySelector('[data-lb-action="zoom-out"]'), zoomInButton: lb.querySelector('[data-lb-action="zoom-in"]'), render: ({ scale, panX, panY }) => { mainImg.style.setProperty('--ldp-lb-scale', String(scale)); mainImg.style.setProperty('--ldp-lb-pan-x', `${Math.round(panX)}px`); mainImg.style.setProperty('--ldp-lb-pan-y', `${Math.round(panY)}px`); }, }); const { render: renderTransform, setZoom, reset: resetView } = imageTransform; const applyCommentsWidth = (value, persist = false) => { commentsWidthPercent = normalizeLightboxCommentsWidth(value); lb.style.setProperty('--ldp-lb-comments-width-preferred', `${commentsWidthPercent}%`); commentsResizer.setAttribute('aria-valuemin', String(LIGHTBOX_COMMENTS_WIDTH_MIN)); commentsResizer.setAttribute('aria-valuemax', String(LIGHTBOX_COMMENTS_WIDTH_MAX)); commentsResizer.setAttribute('aria-valuenow', String(Math.round(commentsWidthPercent))); if (persist && commentsWidthPercent !== normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent)) { setPref('lightboxCommentsWidthPercent', commentsWidthPercent); } requestAnimationFrame(() => renderTransform()); }; applyCommentsWidth(commentsWidthPercent); const totalPostNumber = () => Math.max( 0, +(ctx && ctx.totalPosts || 0), +(ctx && ctx.topicData && ctx.topicData.highest_post_number || 0) ); if (initialPostNumber && totalPostNumber() && initialPostNumber >= totalPostNumber()) { forwardExhausted = true; } const scrollActiveThumbIntoView = () => { const thumb = thumbs[currentIndex]; if (thumb) thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); }; const updateNavigationState = () => { const exploring = exploringPrevious || exploringNext; [ [prevBtn, exploringPrevious, currentIndex === 0, backwardExhausted, ['正在加载更早图片', '已经到头了', '继续点击加载更早图片', '上一张']], [nextBtn, exploringNext, currentIndex === items.length - 1, forwardExhausted, ['正在加载更多图片', '已经到底了', '继续点击加载更多图片', '下一张']], ].forEach(([button, busy, atDiscoveredEdge, exhausted, labels]) => { const atPostEdge = atDiscoveredEdge && exhausted; button.disabled = exploring; button.setAttribute('aria-disabled', String(atPostEdge)); button.setAttribute('aria-busy', String(busy)); button.setAttribute('aria-label', labels[busy ? 0 : atPostEdge ? 1 : atDiscoveredEdge ? 2 : 3]); }); }; const updateJumpToPostState = () => { const postNumber = +(items[currentIndex] && items[currentIndex].postNumber) || 0; jumpToPostBtn.disabled = jumpingToPost || !postNumber; jumpToPostBtn.setAttribute('aria-busy', String(jumpingToPost)); jumpToPostBtn.setAttribute('aria-label', '跳到楼层'); }; const syncThumbs = () => { filmstrip.hidden = items.length < 2; thumbsEl.innerHTML = items.map((item, index) => { return `<button class="ldp-lb-thumb" type="button" role="option" data-lb-index="${index}" aria-label="${escAttr(item.alt || `查看第 ${index + 1} 张图片`)}"><img src="${escAttr(item.thumbSrc)}" alt="" decoding="async"></button>`; }).join(''); thumbs = Array.from(thumbsEl.querySelectorAll('.ldp-lb-thumb')); thumbs.forEach((thumb) => thumb.addEventListener('click', () => showItem(+thumb.dataset.lbIndex))); if (!batchOverlay.hidden) renderBatchSelection(); }; const batchArchivePlaceholder = () => { const title = safeDownloadFilename(ctx && ctx.topicData && ctx.topicData.title || document.title || '帖子图片', '帖子图片'); return `${title}_${lightboxDownloadTimestamp()}.zip`; }; const batchArchiveName = () => { const candidate = String(batchNameInput.value || batchNameInput.placeholder || '').trim().replace(/\.zip$/i, ''); return `${safeDownloadFilename(candidate, `帖子图片_${lightboxDownloadTimestamp()}`)}.zip`; }; const batchScopeItems = () => batchScope === 'all' ? items : items.filter((item) => batchLoadedScopeKeys.has(item.key)); const batchCanLoadMore = () => batchScope === 'loaded' && !fixedItems && !batchLoadedScopeExhausted && ctx && ctx.loader && typeof ctx.loader.loadAfterPost === 'function'; const syncBatchScopeControls = () => { batchScopeButtons.forEach((button) => { const active = button.dataset.lbBatchScope === batchScope; button.setAttribute('aria-pressed', String(active)); button.setAttribute('aria-selected', String(active)); button.tabIndex = active ? 0 : -1; button.disabled = batchBusy || fixedItems && button.dataset.lbBatchScope === 'all'; }); }; const syncBatchSelectionControls = () => { const scopedItems = batchScopeItems(); const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key)); batchSelectAll.setAttribute('aria-pressed', String(allSelected)); batchSelectAll.innerHTML = `${icon(allSelected ? 'squareCheck' : 'square')}<span>${allSelected ? '全不选' : '全选'}</span>`; batchCount.textContent = `已选 ${batchSelection.size} / ${scopedItems.length}`; batchSelectAll.disabled = batchBusy; batchDownloadButton.disabled = batchBusy || batchSelection.size === 0; syncBatchScopeControls(); }; const batchThumbnailPersistentKey = (item) => { const source = absoluteImageUrl(item && (item.thumbSrc || item.originalSrc)); if (!/^https?:/i.test(source)) return ''; try { const key = new URL(source); key.searchParams.set('__ldp_cached_thumbnail', '240-webp-v1'); return key.href; } catch (error) { return ''; } }; const createBatchThumbnail = async (item) => { const persistentKey = batchThumbnailPersistentKey(item); const blob = await fetchLightboxDownloadBlob(item.thumbSrc || item.originalSrc, POST_REQUEST_PRIORITY.background); if (typeof globalThis.createImageBitmap !== 'function') return URL.createObjectURL(blob); let bitmap; try { bitmap = await globalThis.createImageBitmap(blob, { resizeWidth: 240, resizeQuality: 'low' }); } catch (error) { // Some browser-supported image formats cannot be decoded by createImageBitmap. // Keep the fetched image usable instead of leaving a permanently empty card. return URL.createObjectURL(blob); } const width = Math.max(1, Math.min(240, bitmap.width)); const height = Math.max(1, Math.round(bitmap.height * width / bitmap.width)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d', { alpha: true }); if (!context) { bitmap.close(); return URL.createObjectURL(blob); } context.drawImage(bitmap, 0, 0, width, height); bitmap.close(); const thumbnailBlob = await new Promise((resolve) => { canvas.toBlob(resolve, 'image/webp', 0.78); }); const result = thumbnailBlob && thumbnailBlob.size ? thumbnailBlob : blob; if (persistentKey && result === thumbnailBlob) await persistLightboxBlob(persistentKey, result, 'image/webp'); return URL.createObjectURL(result); }; const drainBatchThumbnailQueue = () => { while (!lightboxClosed && batchThumbnailActive < 4 && batchThumbnailQueue.length) { const task = batchThumbnailQueue.shift(); batchThumbnailActive++; Promise.resolve().then(task).catch(() => {}).finally(() => { batchThumbnailActive--; drainBatchThumbnailQueue(); }); } }; const enqueueBatchThumbnail = (task) => { batchThumbnailQueue.push(task); drainBatchThumbnailQueue(); }; const loadBatchThumbnail = (image, item) => { if (!image || !item || image.dataset.ldpBatchThumbLoading === '1') return; const cached = batchThumbnailUrls.get(item.key); if (cached) { image.dataset.ldpBatchThumbState = 'loading'; image.src = cached; return; } image.dataset.ldpBatchThumbLoading = '1'; const displayThumbnail = (source) => { if (lightboxClosed) { URL.revokeObjectURL(source); return; } const previous = batchThumbnailUrls.get(item.key); if (previous && previous !== source) URL.revokeObjectURL(source); else batchThumbnailUrls.set(item.key, source); if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) { image.dataset.ldpBatchThumbState = 'loading'; image.src = batchThumbnailUrls.get(item.key) || source; } }; const generateThumbnail = () => enqueueBatchThumbnail(async () => { if (lightboxClosed || batchThumbnailUrls.has(item.key)) return; try { displayThumbnail(await createBatchThumbnail(item)); } catch (error) { if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) { image.alt = '缩略图加载失败'; image.dataset.ldpBatchThumbState = 'failed'; } } }); const persistentKey = batchThumbnailPersistentKey(item); if (!persistentKey) { generateThumbnail(); return; } void cachedPersistentLightboxBlob(persistentKey).then((cachedThumbnail) => { if (cachedThumbnail) { displayThumbnail(URL.createObjectURL(cachedThumbnail)); return; } generateThumbnail(); }).catch(generateThumbnail); }; const observeBatchThumbnails = () => { if (batchThumbnailObserver) batchThumbnailObserver.disconnect(); const images = batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]'); if (typeof IntersectionObserver !== 'function') { images.forEach((image) => loadBatchThumbnail(image, items[+image.dataset.ldpBatchIndex])); return; } batchThumbnailObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; batchThumbnailObserver.unobserve(entry.target); loadBatchThumbnail(entry.target, items[+entry.target.dataset.ldpBatchIndex]); }); }, { root: batchGrid, rootMargin: '160px 0px' }); images.forEach((image) => batchThumbnailObserver.observe(image)); }; function renderBatchSelection() { const scopedItems = batchScopeItems(); const availableKeys = new Set(scopedItems.map((item) => item.key)); batchSelection.forEach((key) => { if (!availableKeys.has(key)) batchSelection.delete(key); }); const itemMarkup = scopedItems.map((item, index) => { const itemIndex = items.indexOf(item); const selected = batchSelection.has(item.key); const label = `#${item.postNumber || '?'} · 图片 ${index + 1}`; const cachedThumbnail = batchThumbnailUrls.get(item.key); const immediateThumbnail = cachedThumbnail || item.thumbSrc || item.originalSrc; return `<label class="ldp-lb-batch-item${selected ? ' selected' : ''}" data-lb-batch-index="${itemIndex}"> <input type="checkbox" aria-label="选择${escAttr(label)}"${selected ? ' checked' : ''}> <img${immediateThumbnail ? ` src="${escAttr(immediateThumbnail)}" data-ldp-batch-thumb-fallback="${escAttr(immediateThumbnail)}"` : ''} data-ldp-batch-thumb-key="${escAttr(item.key)}" data-ldp-batch-index="${itemIndex}" data-ldp-batch-thumb-state="loading" alt="" decoding="async"> <span>${esc(label)}</span> </label>`; }).join(''); const moreMarkup = batchCanLoadMore() ? `<button class="ldp-lb-batch-more" type="button"${batchBusy ? ' disabled' : ''}>${icon('plus')}<span>${batchBusy ? '正在加载…' : '加载更多图片'}</span></button>` : ''; batchGrid.innerHTML = itemMarkup + moreMarkup; batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]').forEach((image) => { const syncThumbnailState = () => { if (image.naturalWidth > 0) { image.dataset.ldpBatchThumbState = 'loaded'; return; } const generatedSource = batchThumbnailUrls.get(image.dataset.ldpBatchThumbKey); const currentSource = image.currentSrc || image.src; const fallbackSource = image.dataset.ldpBatchThumbFallback || ''; if (generatedSource && currentSource === generatedSource && fallbackSource && fallbackSource !== generatedSource) { batchThumbnailUrls.delete(image.dataset.ldpBatchThumbKey); URL.revokeObjectURL(generatedSource); image.dataset.ldpBatchThumbState = 'loading'; image.src = fallbackSource; return; } image.dataset.ldpBatchThumbState = 'failed'; }; image.addEventListener('load', syncThumbnailState); image.addEventListener('error', syncThumbnailState); if (image.complete) syncThumbnailState(); }); if (!batchBusy) observeBatchThumbnails(); syncBatchSelectionControls(); } const setBatchProgress = (completed, total, label) => { const percent = total > 0 ? Math.max(0, Math.min(100, Math.round(completed / total * 100))) : 0; batchProgress.hidden = false; batchProgress.style.setProperty('--ldp-lb-batch-progress', `${percent}%`); batchProgressCopy.firstElementChild.textContent = label; batchProgressCopy.lastElementChild.textContent = `${percent}%`; batchProgressTrack.setAttribute('aria-valuenow', String(percent)); }; const openBatchDownload = () => { batchSelection.clear(); batchScope = 'loaded'; batchLoadedScopeKeys = new Set(items.map((item) => item.key)); batchLoadedForwardPostNumber = Math.max(0, ...items.map((item) => +item.postNumber || 0), ...(ctx && Array.isArray(ctx.streamItems) ? ctx.streamItems.map((item) => +item.postNumber || 0) : []) ); batchLoadedScopeExhausted = Boolean(totalPostNumber() && batchLoadedForwardPostNumber >= totalPostNumber()); batchNameInput.value = ''; batchNameInput.placeholder = batchArchivePlaceholder(); batchStatus.textContent = '请选择要打包的图片'; batchProgress.hidden = true; batchOverlay.hidden = false; renderBatchSelection(); requestAnimationFrame(() => { if (batchScopeButtons[0]) batchScopeButtons[0].focus({ preventScroll: true }); const firstItem = batchScopeItems()[0]; const firstIndex = firstItem ? items.indexOf(firstItem) : -1; const firstNode = firstIndex >= 0 ? batchGrid.querySelector(`[data-lb-batch-index="${firstIndex}"]`) : null; if (firstIndex >= 0 && firstNode) void openBatchItemPreview(firstIndex, firstNode); }); }; const closeBatchDownload = () => { if (batchBusy) return; const imageViewer = readerPortalQuery('.ldp-avatar-viewer.is-image'); if (imageViewer && typeof imageViewer._ldpClose === 'function') imageViewer._ldpClose(); batchOverlay.hidden = true; batchSelection.clear(); }; const setCommentsExpanded = (expanded, manual = false) => { if (manual) commentsManuallyToggled = true; lb.classList.toggle('ldp-lb-comments-collapsed', !expanded); commentsToggle.setAttribute('aria-expanded', String(!!expanded)); commentsToggle.setAttribute('aria-label', expanded ? '收起图片评论' : '展开图片评论'); requestAnimationFrame(renderTransform); }; const closeCommentForm = () => { commentTargetPost = null; commentForm.hidden = true; commentForm.removeAttribute('aria-busy'); commentForm.dataset.rootComment = '0'; commentInput.value = ''; commentError.textContent = ''; }; const renderCommentSource = (item, sourcePost = null) => { const post = sourcePost || item && item.sourcePost || null; const postNumber = +(item && item.postNumber || post && post.post_number || 0); commentsSource.hidden = !postNumber; commentsDescriptionToggle.hidden = !postNumber; commentsSourceText.textContent = lightboxSourceDescription(post, item); commentsSourceReactions.hidden = true; commentsSourceReactions._ldpPostData = post; commentsSourceReactions.dataset.postId = String(post && post.id || ''); commentsSourceReactions.dataset.postNumber = String(postNumber || ''); if (post && post.id) { renderPostReactions(commentsSourceReactions, ctx); commentsSourceReactions.hidden = !commentsSourceReactions.classList.contains('ldp-has-reactions'); } }; const quoteMatchesCurrentItem = (quote, item) => { if (!quote || +quote.dataset.post !== +item.postNumber) return false; const quotedTopicId = +(quote.dataset.topic || 0); if (quotedTopicId && item.topicId && quotedTopicId !== +item.topicId) return false; const expectedSource = comparableLightboxImageSource(item.originalSrc); return lightboxQuoteContainsImage(quote, item, expectedSource); }; const renderLightboxCommentPost = (post, nested, state, item) => { const displayPost = Object.assign({}, post, { reply_count: 0 }); const node = renderPost(displayPost, nested, ctx, { forceExpanded: true, showParentJump: nested, }); node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed'); node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove()); const content = node.querySelector(':scope > .ldp-content'); const directReference = state.directMatchNumbers.has(+post.post_number); if (content) { if (directReference) { content.querySelectorAll('aside.quote').forEach((quote) => { if (quoteMatchesCurrentItem(quote, item)) quote.remove(); }); } content.querySelectorAll('img:not(.emoji),video,audio,iframe,canvas,source,picture').forEach((media) => media.remove()); content.querySelectorAll('.ldp-quote-controls').forEach((controls) => controls.remove()); content.querySelectorAll('.lightbox-wrapper,figure,a.lightbox').forEach((wrapper) => { if (!String(wrapper.textContent || '').trim() && !wrapper.querySelector('img,video,audio,iframe')) wrapper.remove(); }); } node.querySelectorAll(':scope > .ldp-boost-list,:scope > .ldp-topic-footer-actions').forEach((element) => element.remove()); node.querySelectorAll('.ldp-context-actions-slot,.ldp-boostbtn').forEach((element) => element.remove()); node.querySelectorAll('.ldp-post-head .ldp-floor').forEach((floor) => { if (!floor.dataset.targetPostNumber) floor.dataset.targetPostNumber = String(post.post_number || ''); floor.classList.add('ldp-lb-floor-jump'); if (floor.tagName !== 'BUTTON') { floor.tabIndex = 0; floor.setAttribute('role', 'button'); } }); return node; }; const descendantsForComment = (rootPostNumber, state) => { const descendants = []; const visited = new Set([+rootPostNumber]); const visit = (postNumber) => { const children = state.childrenByParent.get(+postNumber) || []; children.forEach((child) => { const childNumber = +(child && child.post_number || 0); if (!childNumber || visited.has(childNumber)) return; visited.add(childNumber); descendants.push(child); visit(childNumber); }); }; visit(rootPostNumber); return descendants; }; const renderCommentState = (state, item) => { const count = state && state.postsByNumber ? state.postsByNumber.size : 0; commentsToggleCount.textContent = String(count); commentsHeadingCount.textContent = `(${count})`; renderCommentSource(item, state && state.sourcePost); commentsList.replaceChildren(); commentsStatus.hidden = true; commentsEmpty.hidden = count > 0; if (!count) { commentsEmpty.querySelector('span').textContent = state && state.partial ? '暂未找到评论,部分结果加载失败' : '还没有人评论这张图片'; } (state && state.roots || []).forEach((rootPost) => { const thread = document.createElement('section'); thread.className = 'ldp-lb-comment-thread'; const rootWrap = document.createElement('div'); rootWrap.className = 'ldp-lb-comment-node'; rootWrap.append(renderLightboxCommentPost(rootPost, false, state, item)); thread.append(rootWrap); const descendants = descendantsForComment(rootPost.post_number, state); if (descendants.length) { const children = document.createElement('div'); children.className = 'ldp-lb-comment-children'; descendants.forEach((reply) => { const replyWrap = document.createElement('div'); replyWrap.className = 'ldp-lb-comment-node'; replyWrap.append(renderLightboxCommentPost(reply, true, state, item)); children.append(replyWrap); }); thread.append(children); } commentsList.append(thread); }); if (state && state.partial && count > 0) { commentsStatus.hidden = false; commentsStatus.textContent = '部分关联回复暂未加载'; } if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault); }; const openCommentForm = (targetPost = null) => { const item = items[currentIndex]; const rootComment = !targetPost; commentTargetPost = targetPost || item.sourcePost || { id: item.postId, post_number: item.postNumber, username: item.username, }; const username = cleanUsernameValue(commentTargetPost && commentTargetPost.username); const targetNumber = +(commentTargetPost && commentTargetPost.post_number || item.postNumber || 0); commentForm.dataset.rootComment = rootComment ? '1' : '0'; commentTargetLabel.textContent = rootComment ? `${username ? `评论 @${username}` : '评论'} 的图片(回复 #${targetNumber})` : `回复 ${username ? `@${username} · ` : ''}#${targetNumber}`; commentImageOption.hidden = rootComment; commentImageCheckbox.checked = rootComment; commentError.textContent = ''; commentForm.hidden = false; setCommentsExpanded(true); requestAnimationFrame(() => { commentForm.scrollIntoView({ block: 'nearest' }); commentInput.focus({ preventScroll: true }); }); }; const loadCommentsForItem = async (item) => { const token = ++commentRequestToken; closeCommentForm(); renderCommentSource(item); const cached = commentCache.get(item.key); if (cached) { renderCommentState(cached, item); return cached; } commentsToggleCount.textContent = '…'; commentsHeadingCount.textContent = '(…)'; commentsStatus.hidden = false; commentsStatus.textContent = '正在查找这张图片的评论…'; commentsEmpty.hidden = true; commentsList.replaceChildren(); if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault); try { const state = await loadLightboxCommentTree(item, ctx); if (lightboxClosed || token !== commentRequestToken || items[currentIndex].key !== item.key) return null; commentCache.set(item.key, state); renderCommentState(state, item); return state; } catch (error) { if (lightboxClosed || token !== commentRequestToken) return null; commentsToggleCount.textContent = '0'; commentsHeadingCount.textContent = '(0)'; commentsStatus.hidden = false; commentsStatus.textContent = `评论加载失败:${error.message || '请重试'}`; commentsEmpty.hidden = false; commentsEmpty.querySelector('span').textContent = '暂时无法加载图片评论'; return null; } }; const toggleLightboxCommentLike = async (button, postNode) => { const postData = postNode && postNode._ldpPostData; const postId = +(postData && postData.id || postNode && postNode.dataset.postId || 0); if (!postId || !postData || button.disabled) return; await runReaderActionOnce(ctx, `post:${postId}:like`, async () => { await toggleReaderPostLike(postId, ctx, button, { setBusy: (busy) => setReaderControlBusy(button, busy), beforeSync: (acted, count) => syncReaderLikeButton(button, updatePostLikeAction(postData, acted, count)), postData, persist: true, onError: (error) => showSelectionToast(`操作失败:${error.message}`), }); }); }; const positionLightboxReactionPicker = (picker) => { if (!picker || picker.hidden || !commentsPanel.isConnected) return; picker.style.left = '0px'; const panelRect = commentsPanel.getBoundingClientRect(); const safeInset = 10; picker.style.maxWidth = `${Math.max(1, Math.min(320, panelRect.width - safeInset * 2, window.innerWidth - 24))}px`; const pickerRect = picker.getBoundingClientRect(); const safeLeft = Math.max(12, panelRect.left + safeInset); const safeRight = Math.min(window.innerWidth - 12, panelRect.right - safeInset); let shift = 0; if (pickerRect.right > safeRight) shift -= pickerRect.right - safeRight; if (pickerRect.left + shift < safeLeft) shift += safeLeft - (pickerRect.left + shift); picker.style.left = `${Math.round(shift)}px`; }; const setLightboxReactionPickerExpanded = (postNode, expanded) => { const root = postNode && postNode.querySelector(':scope > .ldp-reactions'); const trigger = root && root.querySelector('[data-reaction-picker]'); const picker = root && root.querySelector('.ldp-reaction-picker'); if (!trigger || !picker) return; picker.hidden = !expanded; if (expanded) requestAnimationFrame(() => positionLightboxReactionPicker(picker)); trigger.setAttribute('aria-expanded', String(!!expanded)); trigger.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }; const toggleLightboxReactionPicker = async (postNode) => { const currentPicker = postNode && postNode.querySelector(':scope > .ldp-reactions .ldp-reaction-picker'); const shouldOpen = !!(currentPicker && currentPicker.hidden); if (shouldOpen && !reactionEmojiUrls) { await loadReactionEmojiRegistry(); renderPostReactions(postNode, ctx); } setLightboxReactionPickerExpanded(postNode, shouldOpen); }; const toggleLightboxReaction = async (button, postNode) => { const postData = postNode && postNode._ldpPostData; const postId = +(postData && postData.id || postNode && postNode.dataset.postId || 0); const reaction = String(button && button.dataset.reaction || ''); if (!postId || !reaction) return; const item = items[currentIndex]; await toggleReaderPostReaction(postNode, ctx, reaction, (fresh) => { postNode._ldpPostData = Object.assign({}, postData, fresh); const state = commentCache.get(item.key); const cachedPost = state && state.postsByNumber.get(+(postData && postData.post_number || 0)); if (cachedPost && +cachedPost.id === postId) Object.assign(cachedPost, fresh); if (item.sourcePost && +item.sourcePost.id === postId) Object.assign(item.sourcePost, fresh); renderPostReactions(postNode, ctx); }, (error) => showSelectionToast(`回应失败:${error.message}`)); }; const jumpToLightboxComment = async (post) => { const postNumber = +(post && post.post_number || 0); if (!postNumber || lightboxClosed) return; try { const acceptsWork = () => !lightboxClosed; const jumped = +(post.reply_to_post_number || 0) > 0 ? await focusLiveNestedReply(post, ctx, { acceptsWork }) : await ctx.timeline.jumpTo(postNumber, { acceptsWork }); if (!jumped || lightboxClosed) return; close(); } catch (error) { showSelectionToast('无法定位到该楼层'); } }; const onCommentFormSubmit = async (event) => { event.preventDefault(); if (commentForm.getAttribute('aria-busy') === 'true') return; const item = items[currentIndex]; const targetPost = commentTargetPost; const targetPostNumber = +(targetPost && targetPost.post_number || 0); const message = String(commentInput.value || '').trim(); const messageLength = Array.from(message).length; const includeImage = commentForm.dataset.rootComment === '1' || commentImageCheckbox.checked; const quoteRaw = includeImage ? lightboxImageQuoteRaw(item, ctx) : ''; if (!message || !targetPostNumber || includeImage && !quoteRaw) { commentError.textContent = !message ? '请输入评论内容' : '无法确认图片引用来源'; return; } if (messageLength < minimumCommentLength) { commentError.textContent = `评论至少需要 ${minimumCommentLength} 个字符(当前 ${messageLength} 个)`; return; } commentForm.setAttribute('aria-busy', 'true'); commentError.textContent = ''; try { const payload = await apiSend(`${BASE}/posts`, 'POST', { topic_id: +ctx.topicId, raw: `${quoteRaw}${message}`, reply_to_post_number: targetPostNumber, }, null, { priority: POST_REQUEST_PRIORITY.visible }); const created = postsFromPayload(payload)[0] || payload && payload.post || payload; if (!created || !created.id || !created.post_number) throw new Error('服务器未返回新楼层'); if (!created.reply_to_post_number) created.reply_to_post_number = targetPostNumber; if (!created.topic_id) created.topic_id = +ctx.topicId; if (includeImage) { created._ldpImageReferenceKeys = Array.from(new Set([ ...(Array.isArray(created._ldpImageReferenceKeys) ? created._ldpImageReferenceKeys : []), item.key, ])); } if (!ctx.lightboxImageCommentPosts) ctx.lightboxImageCommentPosts = new Map(); ctx.lightboxImageCommentPosts.set(+created.post_number, created); if (targetPost && targetPost.id) { targetPost.reply_count = Math.max(0, +(targetPost.reply_count || 0)) + 1; persistReaderPostState(ctx, targetPost); } rememberPostData(ctx, created, targetPostNumber); ctx.loader.persistFetchedPosts([created]); ingestLivePosts([created], ctx, { refreshContent: true }); ctx.totalPosts = Math.max(+(ctx.totalPosts || 0), +created.post_number); let state = commentCache.get(item.key); if (!state) state = await loadLightboxCommentTree(item, ctx); state.postsByNumber.set(+created.post_number, created); if (includeImage) state.directMatchNumbers.add(+created.post_number); const tree = buildLightboxCommentTree(state.postsByNumber, state.directMatchNumbers); state.roots = tree.roots; state.childrenByParent = tree.childrenByParent; commentCache.set(item.key, state); persistLightboxCommentRelation(item, ctx, state); commentRequestToken++; closeCommentForm(); if (!lightboxClosed && items[currentIndex].key === item.key) renderCommentState(state, item); } catch (error) { commentError.textContent = `发送失败:${error.message || '请重试'}`; } finally { commentForm.removeAttribute('aria-busy'); } }; const onCommentsClick = (event) => { const addButton = event.target.closest('.ldp-lb-add'); if (addButton) { openCommentForm(); return; } const reactionPost = event.target.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions'); const reactionPicker = event.target.closest('[data-reaction-picker]'); if (reactionPost && reactionPicker) { event.preventDefault(); void toggleLightboxReactionPicker(reactionPost); return; } const reactionButton = event.target.closest('[data-reaction]'); if (reactionPost && reactionButton) { event.preventDefault(); setLightboxReactionPickerExpanded(reactionPost, false); void toggleLightboxReaction(reactionButton, reactionPost); return; } const postNode = event.target.closest('.ldp-lb-comment-node > .ldp-post'); if (!postNode) return; const postData = postNode._ldpPostData; const replyButton = event.target.closest('.ldp-replybtn'); if (replyButton) { event.preventDefault(); openCommentForm(postData); return; } const likeButton = event.target.closest('.ldp-like'); if (likeButton) { event.preventDefault(); void toggleLightboxCommentLike(likeButton, postNode); return; } const floor = event.target.closest('.ldp-lb-floor-jump'); if (floor) { event.preventDefault(); const targetNumber = +(floor.dataset.targetPostNumber || postData && postData.post_number || 0); const state = commentCache.get(items[currentIndex].key); const targetPost = state && state.postsByNumber.get(targetNumber) || knownLightboxPosts(ctx).get(targetNumber); if (targetPost) void jumpToLightboxComment(targetPost); } }; const onCommentsKeydown = (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const floor = event.target.closest('.ldp-lb-floor-jump[role="button"]'); if (!floor) return; event.preventDefault(); floor.click(); }; const showItem = (index) => { currentIndex = Math.max(0, Math.min(items.length - 1, Number(index) || 0)); const item = items[currentIndex]; const previewSrc = item.thumbSrc || item.originalSrc; const hasSeparateOriginal = item.originalSrc !== previewSrc; mainImageRequestToken++; resetView(); countEl.textContent = `${currentIndex + 1} / ${items.length}`; updateNavigationState(); updateJumpToPostState(); thumbs.forEach((thumb, thumbIndex) => { const active = thumbIndex === currentIndex; thumb.classList.toggle('active', active); thumb.setAttribute('aria-selected', String(active)); }); progress.style.setProperty('--ldp-lb-progress-size', `${100 / items.length}%`); progress.style.setProperty('--ldp-lb-progress-x', `${currentIndex * 100}%`); mainImg.hidden = true; mainImg.alt = item.alt; mainImg.dataset.ldpSourceMode = hasSeparateOriginal ? 'preview' : 'original'; viewOriginalBtn.disabled = !hasSeparateOriginal; viewOriginalBtn.removeAttribute('aria-busy'); viewOriginalBtn.setAttribute('aria-label', hasSeparateOriginal ? '查看原图' : '当前已是原图'); status.hidden = false; statusText.textContent = hasSeparateOriginal ? '正在加载预览…' : '正在加载图片…'; retryBtn.hidden = true; mainImg.onload = () => { mainImg.hidden = false; status.hidden = true; void fetchLightboxDownloadBlob(previewSrc, POST_REQUEST_PRIORITY.background).catch(() => {}); if (mainImg.dataset.ldpSourceMode === 'original') { viewOriginalBtn.disabled = true; viewOriginalBtn.removeAttribute('aria-busy'); viewOriginalBtn.setAttribute('aria-label', '当前已是原图'); } renderTransform(); }; mainImg.onerror = () => { mainImg.hidden = true; status.hidden = false; const originalFailed = mainImg.dataset.ldpSourceMode === 'original' && hasSeparateOriginal; statusText.textContent = originalFailed ? '原图加载失败' : '预览图加载失败'; retryBtn.hidden = !originalFailed; viewOriginalBtn.disabled = !hasSeparateOriginal; viewOriginalBtn.removeAttribute('aria-busy'); }; mainImg.removeAttribute('src'); mainImg.src = previewSrc; if (hasSeparateOriginal) { loadCurrentOriginal(false, PREFS.lightboxOriginalByDefault !== true); } if (commentsEnabled) void loadCommentsForItem(item); requestAnimationFrame(scrollActiveThumbIntoView); }; const loadCurrentOriginal = (refresh = false, cachedOnly = false) => { const item = items[currentIndex]; const previewSrc = item && (item.thumbSrc || item.originalSrc); if (!item || !item.originalSrc || item.originalSrc === previewSrc) return; const itemKey = item.key; const requestToken = cachedOnly ? mainImageRequestToken : ++mainImageRequestToken; const requestIsCurrent = () => !lightboxClosed && requestToken === mainImageRequestToken && lb.isConnected && items[currentIndex].key === itemKey; const allowOriginalRetry = (message) => { statusText.textContent = message; retryBtn.hidden = false; viewOriginalBtn.disabled = false; viewOriginalBtn.removeAttribute('aria-busy'); viewOriginalBtn.setAttribute('aria-label', '查看原图'); }; if (!cachedOnly) { viewOriginalBtn.disabled = true; viewOriginalBtn.setAttribute('aria-busy', 'true'); viewOriginalBtn.setAttribute('aria-label', '正在加载原图'); status.hidden = false; statusText.textContent = refresh ? '正在重新加载原图…' : '正在加载原图…'; retryBtn.hidden = true; } const request = cachedOnly ? persistentLightboxImageSource(item.originalSrc, { cachedOnly: true }) : refresh ? deletePersistentLightboxImage(item.originalSrc).then(() => persistentLightboxImageSource(item.originalSrc)) : persistentLightboxImageSource(item.originalSrc); void request.then((source) => { if (!requestIsCurrent()) return; if (cachedOnly && !source) return; if (!source) { allowOriginalRetry('正文请求优先,原图稍后重试'); return; } const originalImage = document.createElement('img'); originalImage.onload = () => { if (!requestIsCurrent()) return; mainImg.dataset.ldpSourceMode = 'original'; mainImg.src = source; }; originalImage.onerror = () => { if (!requestIsCurrent()) return; if (cachedOnly) return; allowOriginalRetry('原图加载失败'); }; originalImage.src = source; }); }; const adjacentItemIsScanned = (direction) => { const item = items[currentIndex + direction]; const postNumber = +(item && item.postNumber) || 0; if (!postNumber) return false; return direction < 0 ? postNumber >= backwardScanPostNumber : postNumber <= forwardScanPostNumber; }; const mergeScannedLightboxItems = (posts) => { const currentKey = items[currentIndex] && items[currentIndex].key; let added = false; posts.forEach((post) => { lightboxItemsFromPost(post, ctx && ctx.topicId).forEach((item) => { if (seenImageKeys.has(item.key)) return; seenImageKeys.add(item.key); items.push(item); added = true; }); }); if (!added) return; items.sort(compareLightboxItems); currentIndex = Math.max(0, items.findIndex((item) => item.key === currentKey)); syncThumbs(); }; const loadMoreBatchImages = async () => { if (batchBusy || !batchCanLoadMore() || lightboxClosed) return; batchBusy = true; batchStatus.textContent = `正在加载 #${batchLoadedForwardPostNumber} 之后的楼层…`; renderBatchSelection(); try { const posts = await ctx.loader.loadAfterPost(batchLoadedForwardPostNumber, { priority: POST_REQUEST_PRIORITY.auxiliary, persistFetchedPosts: true, }); if (lightboxClosed || batchScope !== 'loaded') return; if (!posts.length) { batchLoadedScopeExhausted = true; batchStatus.textContent = '已加载到帖子末尾'; return; } batchLoadedForwardPostNumber = Math.max( batchLoadedForwardPostNumber, ...posts.map((post) => +post.post_number || 0) ); const discoveredKeys = new Set(); posts.forEach((post) => { lightboxItemsFromPost(post, ctx && ctx.topicId).forEach((item) => discoveredKeys.add(item.key)); }); mergeScannedLightboxItems(posts); discoveredKeys.forEach((key) => { if (seenImageKeys.has(key)) batchLoadedScopeKeys.add(key); }); const highest = totalPostNumber(); if (highest && batchLoadedForwardPostNumber >= highest) batchLoadedScopeExhausted = true; batchStatus.textContent = discoveredKeys.size ? `本批新增 ${discoveredKeys.size} 张图片` : batchLoadedScopeExhausted ? '已加载到帖子末尾' : '本批没有图片,可继续加载'; } catch (error) { batchStatus.textContent = `加载更多失败:${error.message || '请重试'}`; } finally { batchBusy = false; if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection(); } }; const scanAllTopicImages = async () => { if (batchBusy || batchAllImagesLoaded || fixedItems || lightboxClosed) return; const topic = ctx && ctx.loader && ctx.loader.topic || ctx && ctx.topicData; const stream = topic && topic.post_stream && Array.isArray(topic.post_stream.stream) ? topic.post_stream.stream.filter(Boolean) : []; if (!stream.length || !ctx || !ctx.loader || typeof ctx.loader.loadPostsByIds !== 'function') { batchStatus.textContent = '未找到完整楼层列表,暂时只能使用当前加载的图片'; return; } batchBusy = true; batchSelection.clear(); batchCloseButtons.forEach((button) => { button.disabled = true; }); renderBatchSelection(); const batchSize = 20; try { for (let offset = 0; offset < stream.length; offset += batchSize) { const end = Math.min(stream.length, offset + batchSize); setBatchProgress(offset, stream.length, `正在扫描全部帖子 ${offset} / ${stream.length}`); batchStatus.textContent = '按阅读器请求速率分批扫描,不请求图片原图'; const posts = await ctx.loader.loadPostsByIds(stream.slice(offset, end), { priority: POST_REQUEST_PRIORITY.auxiliary, persistFetchedPosts: true, }); if (lightboxClosed || batchScope !== 'all') return; mergeScannedLightboxItems(posts); setBatchProgress(end, stream.length, `正在扫描全部帖子 ${end} / ${stream.length}`); } batchAllImagesLoaded = true; batchStatus.textContent = `已扫描全部帖子,共找到 ${items.length} 张图片`; setBatchProgress(stream.length, stream.length, '全部帖子扫描完成'); } catch (error) { batchStatus.textContent = `全帖扫描中断:${error.message || '请重试'}`; } finally { batchBusy = false; batchCloseButtons.forEach((button) => { button.disabled = false; }); if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection(); } }; const exploreAdjacentItem = async (direction) => { const backward = direction < 0; if (!direction || exploringPrevious || exploringNext || lightboxClosed || (backward ? backwardExhausted : forwardExhausted)) return; if (backward) exploringPrevious = true; else exploringNext = true; countEl.textContent = `${currentIndex + 1} / ${items.length} · 正在寻找${backward ? '上一张' : '下一张'}…`; updateNavigationState(); let scanStalled = false; try { const adjacentIndex = currentIndex + direction; if (adjacentIndex >= 0 && adjacentIndex < items.length && adjacentItemIsScanned(direction)) { showItem(adjacentIndex); return; } const scanPostNumber = backward ? backwardScanPostNumber : forwardScanPostNumber; const posts = await (backward ? ctx.loader.loadBeforePost(scanPostNumber, { priority: POST_REQUEST_PRIORITY.visible, persistFetchedPosts: true, }) : ctx.loader.loadAfterPost(scanPostNumber, { priority: POST_REQUEST_PRIORITY.visible, persistFetchedPosts: true, })); const scannedPosts = (Array.isArray(posts) ? posts : []) .filter((post) => backward ? +post.post_number < scanPostNumber : +post.post_number > scanPostNumber) .sort((left, right) => +left.post_number - +right.post_number); if (!scannedPosts.length) { if (backward) { if (backwardScanPostNumber <= 1) backwardExhausted = true; else scanStalled = true; } else { const highestPostNumber = totalPostNumber(); if (!highestPostNumber || forwardScanPostNumber >= highestPostNumber) forwardExhausted = true; else scanStalled = true; } } else { const nextScanPostNumber = backward ? Math.min(...scannedPosts.map((post) => +post.post_number || scanPostNumber)) : Math.max(...scannedPosts.map((post) => +post.post_number || scanPostNumber)); if ((backward && nextScanPostNumber >= backwardScanPostNumber) || (!backward && nextScanPostNumber <= forwardScanPostNumber)) { scanStalled = true; } else { if (backward) backwardScanPostNumber = nextScanPostNumber; else forwardScanPostNumber = nextScanPostNumber; if (lightboxClosed || !lb.isConnected) return; const highestPostNumber = totalPostNumber(); if (backwardScanPostNumber <= 1) backwardExhausted = true; if (highestPostNumber && forwardScanPostNumber >= highestPostNumber) forwardExhausted = true; mergeScannedLightboxItems(scannedPosts); const discoveredIndex = currentIndex + direction; if (discoveredIndex >= 0 && discoveredIndex < items.length && adjacentItemIsScanned(direction)) { showItem(discoveredIndex); return; } } } if (!lightboxClosed) { countEl.textContent = scanStalled ? `${currentIndex + 1} / ${items.length} · ${backward ? '前面' : '后续'}楼层暂未加载,请重试` : `${currentIndex + 1} / ${items.length} · 本批没有图片,再翻一次继续查找`; } } catch (error) { if (!lightboxClosed) countEl.textContent = `查找${backward ? '上一张' : '下一张'}失败,请重试`; } finally { if (backward) exploringPrevious = false; else exploringNext = false; if (!lightboxClosed) updateNavigationState(); } }; const move = (direction) => { const nextIndex = currentIndex + direction; if (nextIndex >= 0 && nextIndex < items.length && adjacentItemIsScanned(direction)) showItem(nextIndex); else void exploreAdjacentItem(direction); }; const downloadCurrent = async () => { const item = items[currentIndex]; const button = lb.querySelector('[data-lb-action="download"]'); if (!item || button.disabled) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.setAttribute('aria-label', '正在准备下载'); try { await downloadLightboxItem(item, currentIndex); } finally { button.disabled = false; button.removeAttribute('aria-busy'); button.setAttribute('aria-label', '下载当前图片'); } }; const downloadBatch = async () => { if (batchBusy || !batchSelection.size) return; const selectedItems = batchScopeItems().filter((item) => batchSelection.has(item.key)); if (!selectedItems.length) return; batchBusy = true; const missingOriginals = await lightboxMissingOriginalCount(selectedItems); const useOriginal = missingOriginals > 0 && globalThis.confirm( `${missingOriginals} 张原图尚未缓存。\n\n确定:按阅读器限速逐张获取原图\n取消:使用每张图片当前最高缓存/预览质量` ); batchStatus.textContent = useOriginal ? '按阅读器请求速率逐张获取原图' : '使用每张图片当前最高缓存/预览质量'; batchCloseButtons.forEach((button) => { button.disabled = true; }); batchNameInput.disabled = true; batchSelectAll.disabled = true; batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = true; }); syncBatchSelectionControls(); const entries = []; const failures = []; try { for (let index = 0; index < selectedItems.length; index++) { const item = selectedItems[index]; setBatchProgress(index, selectedItems.length, `正在获取第 ${index + 1} / ${selectedItems.length} 张`); try { const blob = await lightboxImageDownloadBlob(item, { original: useOriginal }); entries.push({ name: lightboxImageDownloadName(item, index, blob), bytes: new Uint8Array(await blob.arrayBuffer()), }); } catch (error) { failures.push({ item, error }); } setBatchProgress(index + 1, selectedItems.length, `已处理 ${index + 1} / ${selectedItems.length} 张`); } if (!entries.length) throw new Error('所选图片均下载失败'); setBatchProgress(selectedItems.length, selectedItems.length, '正在生成 ZIP 文件…'); const zip = createStoredZip(entries); triggerBrowserDownload(zip, batchArchiveName()); batchStatus.textContent = failures.length ? `已打包 ${entries.length} 张,${failures.length} 张获取失败` : `已打包 ${entries.length} 张图片`; } catch (error) { batchStatus.textContent = `打包失败:${error.message || '请重试'}`; } finally { batchBusy = false; batchCloseButtons.forEach((button) => { button.disabled = false; }); batchNameInput.disabled = false; batchSelectAll.disabled = false; batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = false; }); syncBatchSelectionControls(); } }; let batchPreviewRequestToken = 0; const ensureBatchPreviewSource = async (item, itemIndex) => { const existing = batchThumbnailUrls.get(item.key); if (existing) return existing; const itemNode = batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`); const thumbnailImage = itemNode && itemNode.querySelector('img'); const displayed = thumbnailImage && (thumbnailImage.currentSrc || thumbnailImage.src); if (displayed) return displayed; const persistentKey = batchThumbnailPersistentKey(item); const cached = persistentKey && await cachedPersistentLightboxBlob(persistentKey); if (cached) { const source = URL.createObjectURL(cached); batchThumbnailUrls.set(item.key, source); return source; } return new Promise((resolve, reject) => { enqueueBatchThumbnail(async () => { try { const source = await createBatchThumbnail(item); if (lightboxClosed) { URL.revokeObjectURL(source); reject(new Error('图片预览已关闭')); return; } const previous = batchThumbnailUrls.get(item.key); if (previous) URL.revokeObjectURL(source); else batchThumbnailUrls.set(item.key, source); resolve(previous || source); } catch (error) { reject(error); } }); }); }; const openBatchItemPreview = async (itemIndex, anchorCard) => { if (batchBusy || lightboxClosed || batchOverlay.hidden) return; const item = items[itemIndex]; const scopedItems = batchScopeItems(); const scopedIndex = item ? scopedItems.findIndex((candidate) => candidate.key === item.key) : -1; if (!item || scopedIndex < 0) return; const requestToken = ++batchPreviewRequestToken; try { const previewSource = await ensureBatchPreviewSource(item, itemIndex); if (requestToken !== batchPreviewRequestToken || lightboxClosed || batchOverlay.hidden) return; const updateSelection = (selected) => { if (selected) batchSelection.add(item.key); else batchSelection.delete(item.key); const itemNode = batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`); const input = itemNode && itemNode.querySelector('input[type="checkbox"]'); if (input) input.checked = selected; if (itemNode) itemNode.classList.toggle('selected', selected); syncBatchSelectionControls(); }; openUserAvatarViewer(item.key, '', anchorCard, previewSource, null, null, { kind: 'image', reuseImageViewer: true, source: item.originalSrc, loadOriginal: true, selectable: true, selected: batchSelection.has(item.key), selectionLabel: `${scopedIndex + 1} / ${scopedItems.length} · #${item.postNumber || '?'}`, previousDisabled: scopedIndex === 0, nextDisabled: scopedIndex === scopedItems.length - 1, onPrevious: () => { const previous = scopedItems[scopedIndex - 1]; if (previous) void openBatchItemPreview(items.indexOf(previous), anchorCard); }, onNext: () => { const next = scopedItems[scopedIndex + 1]; if (next) void openBatchItemPreview(items.indexOf(next), anchorCard); }, onSelectionChange: updateSelection, onDownload: () => downloadLightboxItem(item, itemIndex), }); } catch (error) { if (requestToken === batchPreviewRequestToken) showSelectionToast('缩略图加载失败,请重试'); } }; const jumpToCurrentPost = async () => { const currentItem = items[currentIndex]; const postNumber = +(currentItem && currentItem.postNumber) || 0; const topicId = +(currentItem && currentItem.topicId) || +(ctx && ctx.topicId) || 0; if (!postNumber || jumpingToPost || lightboxClosed) return; if (topicId && ctx && topicId !== +ctx.topicId) { close(); openModal(topicId, { targetPostNumber: postNumber }); return; } jumpingToPost = true; updateJumpToPostState(); try { const jumped = !!(ctx && ctx.timeline) && await ctx.timeline.jumpTo(postNumber, { acceptsWork: () => !lightboxClosed, }); if (lightboxClosed) return; if (!jumped) { countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`; return; } close(); } catch (error) { if (!lightboxClosed) countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`; } finally { jumpingToPost = false; if (!lightboxClosed) updateJumpToPostState(); } }; const close = () => { lightboxClosed = true; mainImageRequestToken++; commentRequestToken++; clearTimeout(descriptionHeightSaveTimer); if (descriptionResizeObserver) descriptionResizeObserver.disconnect(); if (batchThumbnailObserver) batchThumbnailObserver.disconnect(); batchThumbnailQueue.length = 0; batchThumbnailUrls.forEach((source) => URL.revokeObjectURL(source)); batchThumbnailUrls.clear(); imageTransform.destroy(); lb.remove(); document.removeEventListener('keydown', onKey); window.removeEventListener('resize', renderTransform); }; let descriptionHeightSaveTimer = 0; const descriptionResizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => { if (!commentsSource.open || commentsSource.hidden || lightboxClosed) return; clearTimeout(descriptionHeightSaveTimer); descriptionHeightSaveTimer = setTimeout(() => { const height = normalizeLightboxDescriptionHeight(commentsSourceText.getBoundingClientRect().height); if (height === normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)) return; setPref('lightboxDescriptionHeight', height); lb.style.setProperty('--ldp-lb-description-height', `${height}px`); }, 160); }) : null; if (descriptionResizeObserver) descriptionResizeObserver.observe(commentsSourceText); commentsSource.addEventListener('toggle', () => { commentsDescriptionToggle.setAttribute('aria-expanded', String(commentsSource.open)); commentsDescriptionToggle.setAttribute('aria-label', commentsSource.open ? '收纳图片描述' : '展开图片描述'); setPref('lightboxDescriptionExpanded', commentsSource.open); }); commentsDescriptionToggle.setAttribute('aria-expanded', String(commentsSource.open)); commentsDescriptionToggle.setAttribute('aria-label', commentsSource.open ? '收纳图片描述' : '展开图片描述'); commentsDescriptionToggle.addEventListener('click', () => { commentsSource.open = !commentsSource.open; }); let commentsResizePointerId = null; commentsResizer.addEventListener('pointerdown', (event) => { if (event.button !== 0 || lb.classList.contains('ldp-lb-comments-collapsed')) return; commentsResizePointerId = event.pointerId; commentsResizer.setPointerCapture(event.pointerId); lb.classList.add('is-resizing-comments'); event.preventDefault(); }); commentsResizer.addEventListener('pointermove', (event) => { if (commentsResizePointerId !== event.pointerId) return; const mainRect = lb.querySelector('.ldp-lb-main').getBoundingClientRect(); if (!mainRect.width) return; const minimum = Math.min( LIGHTBOX_COMMENTS_WIDTH_MAX, Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, 240 / mainRect.width * 100) ); applyCommentsWidth(Math.min( LIGHTBOX_COMMENTS_WIDTH_MAX, Math.max(minimum, (mainRect.right - event.clientX) / mainRect.width * 100) )); }); const finishCommentsResize = (event) => { if (commentsResizePointerId !== event.pointerId) return; commentsResizePointerId = null; lb.classList.remove('is-resizing-comments'); applyCommentsWidth(commentsWidthPercent, true); }; commentsResizer.addEventListener('pointerup', finishCommentsResize); commentsResizer.addEventListener('pointercancel', finishCommentsResize); commentsResizer.addEventListener('keydown', (event) => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); applyCommentsWidth(commentsWidthPercent + (event.key === 'ArrowLeft' ? 2 : -2), true); }); function onKey(e) { if (!lb.isConnected) return; const imageViewer = readerPortalQuery('.ldp-avatar-viewer'); if (imageViewer && imageViewer.isConnected) return; if (e.key === 'Escape') { if (!batchOverlay.hidden) { if (!batchBusy) closeBatchDownload(); e.preventDefault(); e.stopImmediatePropagation(); return; } if (visibleNativeFloatingSurface()) return; e.preventDefault(); e.stopImmediatePropagation(); close(); return; } if (!batchOverlay.hidden) return; if (e.target && e.target.closest && e.target.closest('textarea,input,select,[contenteditable="true"]')) return; if (e.key === 'ArrowLeft') { e.preventDefault(); move(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); move(1); } else handleImageTransformShortcut(e, imageTransform, resetView); } lb.querySelector('.ldp-lb-toolbar').addEventListener('click', (e) => { const button = e.target.closest('[data-lb-action]'); if (!button) return; const action = button.dataset.lbAction; if (action === 'close') close(); else if (action === 'zoom-out') setZoom(imageTransform.scale / 1.2); else if (action === 'zoom-in') setZoom(imageTransform.scale * 1.2); else if (action === 'reset') resetView(); else if (action === 'view-original') loadCurrentOriginal(); else if (action === 'jump-to-post') void jumpToCurrentPost(); else if (action === 'download') void downloadCurrent(); else if (action === 'batch-download') openBatchDownload(); else if (action === 'toggle-comments') { setCommentsExpanded(lb.classList.contains('ldp-lb-comments-collapsed'), true); } }); batchCloseButtons.forEach((button) => button.addEventListener('click', closeBatchDownload)); batchOverlay.addEventListener('pointerdown', (event) => { if (event.target === batchOverlay) closeBatchDownload(); }); batchGrid.addEventListener('wheel', (event) => { if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; const delta = event.deltaMode === 1 ? event.deltaY * 40 : event.deltaMode === 2 ? event.deltaY * batchGrid.clientHeight : event.deltaY; const precisionInput = event.deltaMode === 0 && Math.abs(event.deltaY) < 50; const maximum = Math.max(0, batchGrid.scrollHeight - batchGrid.clientHeight); const nextScrollTop = Math.max(0, Math.min(maximum, batchGrid.scrollTop + delta * (precisionInput ? 1.15 : 1.7))); if (nextScrollTop === batchGrid.scrollTop) return; event.preventDefault(); event.stopPropagation(); batchGrid.scrollTop = nextScrollTop; }, { passive: false }); batchScopeButtons.forEach((button) => button.addEventListener('click', () => { if (batchBusy || button.disabled) return; const nextScope = button.dataset.lbBatchScope === 'all' ? 'all' : 'loaded'; batchScope = nextScope; batchSelection.clear(); batchProgress.hidden = true; batchStatus.textContent = nextScope === 'all' ? batchAllImagesLoaded ? `已扫描全部帖子,共找到 ${items.length} 张图片` : '准备扫描全部帖子…' : '请选择当前已加载的图片'; if (nextScope === 'all' && !batchAllImagesLoaded) { void scanAllTopicImages(); return; } renderBatchSelection(); })); batchSelectAll.addEventListener('click', () => { if (batchBusy) return; const scopedItems = batchScopeItems(); const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key)); batchSelection.clear(); if (!allSelected) scopedItems.forEach((item) => batchSelection.add(item.key)); renderBatchSelection(); }); batchGrid.addEventListener('change', (event) => { const input = event.target.closest('input[type="checkbox"]'); const itemNode = input && input.closest('[data-lb-batch-index]'); const item = itemNode && items[+itemNode.dataset.lbBatchIndex]; if (!item || batchBusy) return; if (input.checked) batchSelection.add(item.key); else batchSelection.delete(item.key); itemNode.classList.toggle('selected', input.checked); syncBatchSelectionControls(); }); batchGrid.addEventListener('click', (event) => { const loadMoreButton = event.target.closest('.ldp-lb-batch-more'); if (loadMoreButton) { event.preventDefault(); void loadMoreBatchImages(); return; } const image = event.target.closest('.ldp-lb-batch-item img'); const itemNode = image && image.closest('[data-lb-batch-index]'); const itemIndex = itemNode ? +itemNode.dataset.lbBatchIndex : -1; const item = itemIndex >= 0 ? items[itemIndex] : null; if (!item || batchBusy) return; event.preventDefault(); event.stopPropagation(); void openBatchItemPreview(itemIndex, itemNode); }); batchDownloadButton.addEventListener('click', () => { void downloadBatch(); }); prevBtn.addEventListener('click', () => move(-1)); nextBtn.addEventListener('click', () => move(1)); retryBtn.addEventListener('click', () => loadCurrentOriginal(true)); stage.addEventListener('wheel', (e) => { e.preventDefault(); const nextScale = imageTransform.scale * (e.deltaY < 0 ? 1.15 : 1 / 1.15); if (e.target === mainImg) setZoom(nextScale, e.clientX, e.clientY); else setZoom(nextScale); }, { passive: false }); stage.addEventListener('dblclick', (e) => { if (e.target !== mainImg) return; const nativeScale = mainImg.clientWidth ? Math.min(8, mainImg.naturalWidth / mainImg.clientWidth) : 1; setZoom(imageTransform.scale > 1.05 ? 1 : Math.max(2, nativeScale)); }); lb.querySelector('.ldp-lb-comments').addEventListener('click', onCommentsClick); lb.querySelector('.ldp-lb-comments').addEventListener('keydown', onCommentsKeydown); commentForm.addEventListener('submit', onCommentFormSubmit); commentForm.querySelector('.ldp-lb-comment-cancel').addEventListener('click', closeCommentForm); document.addEventListener('keydown', onKey); window.addEventListener('resize', renderTransform); syncThumbs(); appendReaderPortalNode(lb); showItem(initialIndex); } function resolveOriginalSrc(imgEl) { const a = imgEl.closest('a.lightbox, a[href]'); if (a && a.getAttribute('href')) { const href = a.getAttribute('href'); if (/\.(png|jpe?g|gif|webp|bmp|avif)(\?|#|$)/i.test(href) || a.classList.contains('lightbox')) { return href; } } return imgEl.getAttribute('data-large-src') || imgEl.currentSrc || imgEl.src; } /* ============ 4. 已读追踪器 ============ */ function createReadTracker(topicId, scrollRoot, requestScheduler, onReadConfirmed) { const dwell = new Map(); const reported = new Map(); const observedNodes = new Set(); const viewportNodes = new Set(); const pendingVisibleCallbacks = new Map(); let tickTimer = null, flushTimer = null; let started = false, flushRunning = false, initialReadFlushPending = false; let readFlushQueued = false; let initialReadRetryAt = 0; let readChallengeAttempted = false; const notifyReadConfirmed = (postNumbers) => { if (typeof onReadConfirmed !== 'function') return; try { onReadConfirmed(postNumbers); } catch (error) { console.warn('[LDP] sync confirmed post read state failed', error); } }; const io = new IntersectionObserver((entries) => { entries.forEach((en) => { const pn = +en.target.dataset.postNumber; if (!pn) return; const intersection = en.intersectionRect; const inViewport = en.isIntersecting && intersection.width > 0 && intersection.height > 0; if (inViewport) viewportNodes.add(en.target); else viewportNodes.delete(en.target); const onVisible = inViewport && pendingVisibleCallbacks.get(en.target); if (onVisible) { pendingVisibleCallbacks.delete(en.target); onVisible(); if (!observedNodes.has(en.target)) { viewportNodes.delete(en.target); io.unobserve(en.target); } } }); }, { root: scrollRoot, threshold: 0 }); const tick = () => { if (initialReadFlushPending && Date.now() >= initialReadRetryAt) { void flush({ force: true, priority: POST_REQUEST_PRIORITY.visible }); } }; const scheduleReadFlush = () => { if (readFlushQueued) return; readFlushQueued = true; queueMicrotask(() => { readFlushQueued = false; if (!started || !initialReadFlushPending || Date.now() < initialReadRetryAt) return; void flush({ force: true, priority: POST_REQUEST_PRIORITY.visible }); }); }; const flush = async (options = {}) => { if (flushRunning) return false; if (initialReadRetryAt > Date.now()) return false; const requestState = requestScheduler && requestScheduler.snapshot(); if (options.force !== true && requestState && ( requestState.active > 0 || requestState.queued > 0 || requestState.coolingDown )) return false; const params = { topic_id: topicId }; let total = 0, any = false; const submittedPostNumbers = []; dwell.forEach((ms, pn) => { if (ms < READ_THRESHOLD) return; const inc = ms - (reported.get(pn) || 0); if (inc <= 0) return; params[`timings[${pn}]`] = inc; total += inc; reported.set(pn, ms); submittedPostNumbers.push(pn); any = true; }); if (!any) { initialReadFlushPending = false; return false; } flushRunning = true; const retryInitialReadFlush = initialReadFlushPending; initialReadFlushPending = false; params.topic_time = total; let challengeRecovery = null; try { await apiSend( `${BASE}/topics/timings`, 'POST', params, { 'Accept': '*/*', 'Discourse-Background': 'true', 'Discourse-Logged-In': 'true', 'Discourse-Present': 'true', 'X-SILENCE-LOGGER': 'true', }, { priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background, } ); initialReadRetryAt = 0; notifyReadConfirmed(submittedPostNumbers); return true; } catch (e) { initialReadFlushPending = initialReadFlushPending || retryInitialReadFlush; if (initialReadFlushPending) { initialReadRetryAt = Date.now() + (e && e.cloudflareMitigated ? 5 * 60 * 1000 : FLUSH_INTERVAL); } Object.keys(params).forEach((k) => { const m = k.match(/^timings\[(\d+)\]$/); if (m) reported.set(+m[1], (reported.get(+m[1]) || 0) - params[k]); }); if (e && e.cloudflareMitigated && !readChallengeAttempted) { readChallengeAttempted = true; challengeRecovery = { params: Object.assign({}, params), postNumbers: submittedPostNumbers.slice(), }; } } finally { flushRunning = false; if (initialReadFlushPending && Date.now() >= initialReadRetryAt) scheduleReadFlush(); } if (challengeRecovery) { const confirmedByFormPost = () => { challengeRecovery.postNumbers.forEach((postNumber) => { const increment = Number(challengeRecovery.params[`timings[${postNumber}]`]) || 0; reported.set(postNumber, Math.max(0, reported.get(postNumber) || 0) + increment); }); initialReadRetryAt = 0; initialReadFlushPending = false; notifyReadConfirmed(challengeRecovery.postNumbers); }; openCloudflareChallengePopup(`${BASE}/topics/timings`, { formParams: challengeRecovery.params, onFormSuccess: confirmedByFormPost, verify: async () => { initialReadRetryAt = 0; return flush({ force: true, priority: POST_REQUEST_PRIORITY.visible }); }, }); } return false; }; const pauseTimers = () => { clearInterval(tickTimer); clearInterval(flushTimer); tickTimer = null; flushTimer = null; }; const resumeTimers = () => { if (!started || document.visibilityState !== 'visible') return; if (!tickTimer) tickTimer = setInterval(tick, 1000); if (!flushTimer) flushTimer = setInterval(flush, FLUSH_INTERVAL); }; const onVisibilityChange = () => { if (!started) return; if (document.visibilityState === 'visible') { resumeTimers(); return; } tick(); pauseTimers(); flush({ force: true }); }; return { observe(node) { if (!node) return; observedNodes.add(node); io.observe(node); const postNumber = +(node.dataset && node.dataset.postNumber || 0); if (postNumber && !dwell.has(postNumber)) { dwell.set(postNumber, READ_THRESHOLD); initialReadFlushPending = true; if (started) scheduleReadFlush(); } }, unobserve(node) { if (!node) return; observedNodes.delete(node); viewportNodes.delete(node); io.unobserve(node); }, runWhenVisible(node, callback) { if (!node || typeof callback !== 'function') return false; if (viewportNodes.has(node) || readerPostNodeInViewport(node, scrollRoot)) { callback(); return true; } pendingVisibleCallbacks.set(node, callback); io.observe(node); return false; }, start() { if (started) return; started = true; document.addEventListener('visibilitychange', onVisibilityChange); resumeTimers(); if (initialReadFlushPending) scheduleReadFlush(); }, stop() { if (!started) return; if (document.visibilityState === 'visible') tick(); started = false; pauseTimers(); document.removeEventListener('visibilitychange', onVisibilityChange); io.disconnect(); observedNodes.clear(); viewportNodes.clear(); pendingVisibleCallbacks.clear(); flush({ force: true }); }, retryPending() { initialReadRetryAt = 0; if (initialReadFlushPending) { return flush({ force: true, priority: POST_REQUEST_PRIORITY.visible }); } return Promise.resolve(false); }, }; } /* ============ 5. 分块加载(init 带 Discourse 浏览追踪头 + 重试保护) ============ */ function createLoader(topicId, requestScheduler, runtimeConfig) { const pageSize = runtimeConfig.pageSize; const onlyOpBatchFloor = Math.min( PERFORMANCE_LIMITS.pageSize.max, Math.max(pageSize, runtimeConfig.requestMaxConcurrent <= 1 ? 32 : 40) ); let onlyOpPageSize = onlyOpBatchFloor; let stream = []; const cache = new Map(); const postByNumber = new Map(); const unavailablePostNumbers = new Set(); const pendingPostRequests = new Map(); let streamIndexById = new Map(); const requestController = new AbortController(); let cursor = 0; let onlyOpCursor = 0; let onlyOpSkippedCount = 0; let topic = null; let failStreak = 0; let onlyOpFailStreak = 0; let refreshRequest = null; let initializedFromCache = false; let stopTopicCacheSync = () => {}; const growOnlyOpBatch = () => { let requestState = null; try { requestState = requestScheduler && requestScheduler.snapshot(); } catch (error) {} const shortPressure = requestState ? Number(requestState.shortWindowCount || 0) / Math.max(1, Number(requestState.shortWindowBudget) || 1) : 0; const longPressure = requestState ? Number(requestState.longWindowCount || 0) / Math.max(1, Number(requestState.longWindowBudget) || 1) : 0; const pressured = requestState && ( requestState.coolingDown || ['10s', '60s'].includes(requestState.blockingReason) || Number(requestState.globalQueued || 0) > 0 || Math.max(shortPressure, longPressure) >= 0.7 ); onlyOpPageSize = Math.min( PERFORMANCE_LIMITS.pageSize.max, onlyOpPageSize + (pressured ? 12 : 8) ); }; const adjustOnlyOpBatchAfterError = (error) => { const status = Number(error && error.status || 0); if (status === 408 || status === 429 || status >= 500) { onlyOpPageSize = PERFORMANCE_LIMITS.pageSize.max; return; } onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8); }; const fetchLoaderJSON = (url, options = {}) => fetchJSON(url, { scheduler: requestScheduler, signal: requestController.signal, fetchOptions: options.fetchOptions, cacheMode: options.cacheMode || 'default', key: options.key || `post:${url}`, priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible, }); const cachePost = (post) => { if (!post || !post.id) return; const previous = cache.get(post.id); const completePost = previous && previous !== post ? Object.assign(previous, post) : post; cache.set(post.id, completePost); const postNumber = +(completePost.post_number || 0); if (postNumber) postByNumber.set(postNumber, completePost); }; const cachePosts = (posts) => (posts || []).forEach(cachePost); const appendMissingTopicPosts = (topicData, posts) => { if (!topicData || !Array.isArray(posts) || !posts.length) return false; if (!topicData.post_stream) topicData.post_stream = {}; const current = Array.isArray(topicData.post_stream.posts) ? topicData.post_stream.posts : []; const indexesById = new Map(); current.forEach((post, index) => { if (post && post.id) indexesById.set(post.id, index); }); let changed = false; posts.forEach((post) => { if (!post || !post.id) return; const index = indexesById.get(post.id); if (Number.isInteger(index)) { const previous = current[index]; if (previous === post) return; current[index] = previous ? Object.assign(previous, post) : post; changed = true; return; } indexesById.set(post.id, current.length); current.push(post); changed = true; }); if (changed) topicData.post_stream.posts = current; return changed; }; const persistFetchedPosts = (posts) => { if (!Array.isArray(posts) || !posts.length) return; const entry = TOPIC_DATA_CACHE.get(String(topicId)); const persistedTopic = (entry && entry.topic) || topic; if (!persistedTopic) return; const changed = appendMissingTopicPosts(persistedTopic, posts); if (topic && topic !== persistedTopic) appendMissingTopicPosts(topic, posts); if (changed) setCachedTopicData(topicId, persistedTopic); }; const persistViewedPostNumbers = (postNumbers) => { const posts = Array.from(new Set((postNumbers || []).map(Number).filter(Boolean))) .map((postNumber) => postByNumber.get(postNumber)) .filter(Boolean); persistFetchedPosts(posts); }; function normalizeTopicData(data) { const posts = Array.isArray(data.post_stream && data.post_stream.posts) ? data.post_stream.posts : []; const op = (data.details && data.details.created_by && data.details.created_by.username) || (posts.find((p) => p.post_number === 1) || {}).username || null; data._opUsername = op; return { posts, stream: (data.post_stream && data.post_stream.stream) || posts.map((p) => p.id).filter(Boolean), }; } function primeTopicData(data) { const normalized = normalizeTopicData(data); topic = data; stream = normalized.stream; streamIndexById = new Map(stream.map((id, index) => [id, index])); cachePosts(normalized.posts); return topic; } const syncTopicCacheData = (incomingTopic) => { if (!incomingTopic) return; const incoming = normalizeTopicData(incomingTopic); cachePosts(incoming.posts); if (incomingTopic === topic) return; if (!topic) return; appendMissingTopicPosts(topic, incoming.posts); Object.keys(incomingTopic).forEach((key) => { if (key !== 'post_stream') topic[key] = incomingTopic[key]; }); if (incoming.stream.length >= stream.length) { stream = incoming.stream.slice(); streamIndexById = new Map(stream.map((id, index) => [id, index])); } }; stopTopicCacheSync = subscribeTopicCache(topicId, syncTopicCacheData); async function refreshTopicData({ prime = true, priority } = {}) { ensureMe(); if (!refreshRequest) { refreshRequest = (async () => { const url = `${BASE}/t/${topicId}.json?track_visit=true&forceLoad=true`; const data = await fetchJSON(url, { scheduler: requestScheduler, signal: requestController.signal, cacheMode: 'refresh', fetchOptions: { method: 'GET', credentials: 'include', cache: 'no-store', headers: { 'Accept': 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', 'Discourse-Present': 'true', 'Discourse-Track-View': 'true', 'Discourse-Track-View-Topic-Id': String(topicId), }, }, key: `topic:${topicId}:refresh`, priority: Number.isFinite(priority) ? priority : topic ? POST_REQUEST_PRIORITY.background : POST_REQUEST_PRIORITY.topic, }); const entry = TOPIC_DATA_CACHE.get(String(topicId)); const previousTopic = (entry && entry.topic) || topic; if (previousTopic && previousTopic.post_stream) { appendMissingTopicPosts(data, previousTopic.post_stream.posts); } normalizeTopicData(data); setCachedTopicData(topicId, data, { fetchedAt: responseCacheValueStoredAt(data) || Date.now(), source: 'network', }); return data; })().finally(() => { refreshRequest = null; }); } const data = await refreshRequest; if (prime) return primeTopicData(data); return data; } async function init() { if (topic) return topic; await ensureMe(); const previewTopic = getCachedTopicData(topicId); const storedTopic = await getStoredTopicSnapshot(topicId); const cachedTopic = storedTopic || previewTopic; const cacheEntry = TOPIC_DATA_CACHE.get(String(topicId)); if (cachedTopic && cacheEntry) { initializedFromCache = true; primeTopicData(cachedTopic); const expectedPostCount = Math.max(0, Number(topic.posts_count) || 0); if (expectedPostCount > stream.length) { initializedFromCache = false; return refreshTopicData(); } const fetchedAt = topicCacheEntryTimestamp(cacheEntry); if (!fetchedAt || Date.now() - fetchedAt > TOPIC_CACHE_TTL) { refreshTopicData({ prime: false, priority: POST_REQUEST_PRIORITY.background }).catch(() => {}); } return topic; } initializedFromCache = false; return refreshTopicData(); } async function next(options = {}) { const onlyOpScan = options.onlyOp === true; const position = onlyOpScan ? onlyOpCursor : cursor; if (position >= stream.length) return { posts: [], done: true }; const batchSize = onlyOpScan ? onlyOpPageSize : pageSize; const slice = stream.slice(position, position + batchSize); const resetFailures = () => { if (onlyOpScan) onlyOpFailStreak = 0; else failStreak = 0; }; const bumpFailures = () => onlyOpScan ? ++onlyOpFailStreak : ++failStreak; const advance = (count, skipped = 0) => { if (onlyOpScan) { onlyOpCursor += count; onlyOpSkippedCount += skipped; return onlyOpCursor >= stream.length; } cursor += count; return cursor >= stream.length; }; const prepareOnlyOpPosts = (posts) => { if (!onlyOpScan) return posts; const opUsername = String(options.onlyOpUsername || ''); if (!opUsername) return posts; const matches = []; posts.forEach((post) => { if (String(post && post.username || '') === opUsername) { matches.push(post); return; } if (post && post.id && cache.get(post.id) === post) cache.delete(post.id); const postNumber = +(post && post.post_number || 0); if (postNumber && postByNumber.get(postNumber) === post) postByNumber.delete(postNumber); }); if (matches.length) persistFetchedPosts(matches); return matches; }; const finishBatch = (skipped = 0, growOnlyOp = false) => { const done = advance(slice.length, skipped); resetFailures(); const posts = prepareOnlyOpPosts(slice.map((id) => cache.get(id)).filter(Boolean)); if (growOnlyOp && onlyOpScan) growOnlyOpBatch(); return { posts, done }; }; let missing = slice.filter((id) => !cache.has(id)); if (typeof options.onSource === 'function') { options.onSource(missing.length ? 'network' : 'cache', { cachedCount: slice.length - missing.length, missingCount: missing.length, totalCount: slice.length, }); } let loadError = null; if (missing.length) { try { await loadPostsByIds(slice, { priority: POST_REQUEST_PRIORITY.scroll, persistFetchedPosts: !onlyOpScan && options.persistFetchedPosts === true, }); } catch (error) { loadError = error; } missing = slice.filter((id) => !cache.has(id)); } if (missing.length) { if (loadError) { if (onlyOpScan) adjustOnlyOpBatchAfterError(loadError); const status = Number(loadError.status || 0); if (status === 401 || status === 403) { return { posts: [], done: false, fatal: true, error: loadError }; } if (!status || shouldStopReaderFallback(loadError)) { resetFailures(); return { posts: [], done: false, retry: true, error: loadError }; } if (bumpFailures() < 4) return { posts: [], done: false, retry: true, error: loadError }; return finishBatch(missing.length); } if (onlyOpScan) onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8); if (bumpFailures() >= 4) { return finishBatch(missing.length); } return { posts: [], done: false, retry: true }; } return finishBatch(0, true); } async function requestMissingPostIds(ids, priority, shouldPersist) { const missing = ids.filter((id) => !cache.has(id)); const unclaimed = missing.filter((id) => !pendingPostRequests.has(id)); if (unclaimed.length) { const requestIds = Array.from(new Set(unclaimed)); const requestKey = requestIds.slice().sort((a, b) => String(a).localeCompare(String(b))).join(','); const qs = requestIds.map((id) => `post_ids[]=${id}`).join('&'); const request = fetchLoaderJSON(`${BASE}/t/${topicId}/posts.json?${qs}`, { key: `posts:${topicId}:${requestKey}`, priority, }).then((part) => { cachePosts(part && part.post_stream && part.post_stream.posts); }).finally(() => { requestIds.forEach((id) => { if (pendingPostRequests.get(id) === request) pendingPostRequests.delete(id); }); }); requestIds.forEach((id) => pendingPostRequests.set(id, request)); } const requests = Array.from(new Set( missing.map((id) => pendingPostRequests.get(id)).filter(Boolean) )); if (requests.length) await Promise.all(requests); if (shouldPersist) persistFetchedPosts(missing.map((id) => cache.get(id)).filter(Boolean)); } async function loadPostsByIds(ids, options = {}) { const requestedIds = Array.from(new Set((ids || []).filter(Boolean))); let missing = requestedIds.filter((id) => !cache.has(id)); let lastError = null; const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible; const maxAttempts = Math.max(1, +(options.maxAttempts || 2)); for (let attempt = 0; attempt < maxAttempts && missing.length; attempt++) { try { await requestMissingPostIds(missing, priority, options.persistFetchedPosts === true); lastError = null; } catch (error) { lastError = error; const status = Number(error && error.status || 0); const splitDepth = Math.max(0, Number(options.clientSplitDepth) || 0); const canSplitClientError = [400, 404, 413, 414, 422].includes(status) && missing.length > 1 && splitDepth < 1; if (canSplitClientError) { const middle = Math.ceil(missing.length / 2); const splitOptions = { ...options, maxAttempts: 1, clientSplitDepth: splitDepth + 1 }; const results = await Promise.allSettled([ loadPostsByIds(missing.slice(0, middle), splitOptions), loadPostsByIds(missing.slice(middle), splitOptions), ]); const authFailure = results.find((result) => result.status === 'rejected' && [401, 403].includes(Number(result.reason && result.reason.status || 0))); if (authFailure) throw authFailure.reason; const transientFailure = results.find((result) => result.status === 'rejected' && ( !Number(result.reason && result.reason.status || 0) || shouldStopReaderFallback(result.reason) )); if (transientFailure) throw transientFailure.reason; lastError = null; break; } if (shouldStopReaderFallback(error) || (status >= 400 && status < 500)) break; } missing = requestedIds.filter((id) => !cache.has(id)); if (missing.length && attempt + 1 < maxAttempts) { await waitForDelay(240); } } if (missing.length && lastError) throw lastError; const posts = requestedIds.map((id) => cache.get(id)).filter(Boolean); return posts; } function streamIndexForPostNumber(postNumber) { const target = +postNumber; const cachedPost = postByNumber.get(target); const cachedIndex = cachedPost && streamIndexById.get(cachedPost.id); if (Number.isInteger(cachedIndex)) return cachedIndex; return Math.min(stream.length - 1, Math.max(0, target - 1)); } function advanceCursorPast(posts, targetPostNumber) { if (!stream.length) return; let maxIndex = -1; posts.forEach((p) => { const byId = streamIndexById.get(p.id); if (Number.isInteger(byId)) { maxIndex = Math.max(maxIndex, byId); } else if (p.post_number) { maxIndex = Math.max(maxIndex, Math.min(stream.length - 1, Math.max(0, +p.post_number - 1))); } }); if (maxIndex < 0 && targetPostNumber) { maxIndex = Math.min(stream.length - 1, Math.max(0, +targetPostNumber - 1)); } if (maxIndex >= cursor) cursor = maxIndex + 1; } async function loadRelativePosts(postNumber, direction, options = {}) { if (!stream.length) return []; const last = direction === 'last'; const targetIndex = last ? stream.length - 1 : streamIndexForPostNumber(postNumber); if (last && !stream[targetIndex]) return []; if (direction === 'before' && targetIndex <= 0) return []; const start = last ? targetIndex : direction === 'before' ? Math.max(0, targetIndex - pageSize) : Math.min(stream.length, targetIndex + 1); const end = last ? stream.length : direction === 'before' ? targetIndex : start + pageSize; const posts = await loadPostsByIds(stream.slice(start, end), { priority: Number.isFinite(options.priority) ? options.priority : last ? POST_REQUEST_PRIORITY.target : POST_REQUEST_PRIORITY.scroll, persistFetchedPosts: !last && options.persistFetchedPosts === true, }); if (last && posts.length) advanceCursorPast(posts); return posts; } async function loadPost(postNumber, options = {}) { const target = +postNumber; if (!target || unavailablePostNumbers.has(target)) return []; const around = options.scope === 'around'; const shouldAdvance = around || options.advanceCursor !== false; const forceRefresh = !around && options.forceRefresh === true; const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.scroll; const cached = around || forceRefresh ? null : postByNumber.get(target); if (cached) { if (typeof options.onSource === 'function') options.onSource('cache'); if (shouldAdvance) advanceCursorPast([cached], target); return [cached]; } if (typeof options.onSource === 'function') options.onSource('network'); if (!around) { const highestPostNumber = Math.max(0, Number(topic && topic.highest_post_number) || 0); const id = (!highestPostNumber || highestPostNumber === stream.length) ? stream[target - 1] : null; const posts = id && !forceRefresh ? await loadPostsByIds([id], { priority }) : []; const hit = posts.find((post) => +post.post_number === target); if (hit) { if (shouldAdvance) advanceCursorPast([hit], target); return [hit]; } } const slug = encodeURIComponent((topic && topic.slug) || 'topic'); const urls = around ? [ `${BASE}/t/${slug}/${topicId}/${target}.json`, `${BASE}/t/${slug}/${topicId}.json?post_number=${target}`, `${BASE}/t/${topicId}.json?post_number=${target}`, `${BASE}/posts/by_number/${topicId}/${target}.json`, ] : [ `${BASE}/posts/by_number/${topicId}/${target}.json`, `${BASE}/t/${slug}/${topicId}/${target}.json`, `${BASE}/t/${topicId}.json?post_number=${target}`, ]; let fallbackPosts = []; for (const url of urls) { try { const payload = await fetchLoaderJSON(url, { key: `${forceRefresh ? 'force-' : ''}${around ? 'around' : 'single'}:${topicId}:${target}:${url}`, priority, fetchOptions: forceRefresh ? { cache: 'no-store' } : {}, cacheMode: forceRefresh ? 'refresh' : 'default', }); const fetched = postsFromPayload(payload).filter((p) => p && p.id && p.post_number); cachePosts(fetched); const found = fetched.find((p) => +p.post_number === target); if (found) { if (shouldAdvance) advanceCursorPast(around ? fetched : [found], target); return around ? fetched : [found]; } if (around && fetched.length && !fallbackPosts.length) fallbackPosts = fetched; } catch (error) { const status = Number(error && error.status || 0); if (!around && (status === 404 || status === 410) && url.includes('/posts/by_number/')) { unavailablePostNumbers.add(target); return []; } if (shouldStopReaderFallback(error)) throw error; } } if (around && stream.length) { const start = Math.max(0, Math.min(stream.length - pageSize, target - 1 - Math.floor(pageSize / 2))); const ids = stream.slice(start, start + pageSize); if (ids.length) { try { const posts = await loadPostsByIds(ids, { priority }); if (posts.length) { advanceCursorPast(posts, target); return posts; } } catch (error) { if (shouldStopReaderFallback(error)) throw error; } } } if (around && fallbackPosts.length) advanceCursorPast(fallbackPosts, target); return around ? fallbackPosts : []; } return { init, next, loadPostsByIds, loadPost, loadBeforePost: (postNumber, options) => loadRelativePosts(postNumber, 'before', options), loadAfterPost: (postNumber, options) => loadRelativePosts(postNumber, 'after', options), loadLastPost: (options) => loadRelativePosts(0, 'last', options), refreshTopicData, persistViewedPostNumbers, persistFetchedPosts, destroy() { stopTopicCacheSync(); stopTopicCacheSync = () => {}; requestController.abort(); stream.length = 0; cache.clear(); postByNumber.clear(); unavailablePostNumbers.clear(); pendingPostRequests.clear(); streamIndexById.clear(); topic = null; refreshRequest = null; }, get topic() { return topic; }, get cachedPosts() { return Array.from(cache.values()); }, get initializedFromCache() { return initializedFromCache; }, get loadDone() { return cursor >= stream.length; }, get scanDone() { return onlyOpCursor >= stream.length; }, get scanCursor() { return onlyOpCursor; }, get scanSkippedCount() { return onlyOpSkippedCount; }, get scanBatchSize() { return onlyOpPageSize; }, get streamLength() { return stream.length; }, }; } /* ============ 6. 楼层归位 ============ */ function streamItemParticipatesInZebra(item) { return !!(item && item.node) && !item.zebraExcluded && !item.node.classList.contains('ldp-nested-collapsed') && !item.node.classList.contains('ldp-nested-preview'); } function reindexStreamItems(ctx, start = 0) { const items = ctx && ctx.streamItems; if (!items || !items.length) return; const from = Math.max(0, Math.min(items.length - 1, +start || 0)); let zebraIndex = from > 0 ? +(items[from - 1].zebraCountAfter || 0) : 0; for (let index = from; index < items.length; index++) { const item = items[index]; const participates = streamItemParticipatesInZebra(item); item.index = index; item.zebraIndex = participates ? zebraIndex++ : -1; item.zebraCountAfter = zebraIndex; } } function insertStreamPost(ctx, node, postNumber, options = {}) { const current = +postNumber; if (!current || ctx.streamItemMap.has(current)) return; let low = 0; let high = ctx.streamItems.length; const compare = (item) => { if (!ctx.isPostVoting) return item.postNumber - current; if (item.postNumber === 1 || current === 1) return item.postNumber === current ? 0 : (item.postNumber === 1 ? -1 : 1); const left = +(item.node && item.node._ldpPostData && item.node._ldpPostData.post_voting_vote_count || 0); const right = +(node._ldpPostData && node._ldpPostData.post_voting_vote_count || 0); return right - left || item.postNumber - current; }; while (low < high) { const mid = Math.floor((low + high) / 2); if (compare(ctx.streamItems[mid]) < 0) low = mid + 1; else high = mid; } const estimatedHeight = Math.max( STREAM_ESTIMATED_HEIGHT, postContentPlaceholderHeight(node._ldpPostData) + 72 ); if (ctx.streamMountedNodes.size && ctx.streamWindowEnd > ctx.streamWindowStart) { if (low < ctx.streamWindowStart) { ctx.streamWindowStart++; ctx.streamWindowEnd++; } else if (low < ctx.streamWindowEnd) { ctx.streamWindowEnd++; } } const item = { postNumber: current, username: String((node._ldpPostData && node._ldpPostData.username) || node.dataset.username || ''), node, zebraExcluded: options.zebraExcluded === true, height: estimatedHeight, index: low, }; ctx.streamItems.splice(low, 0, item); ctx.streamItemMap.set(current, item); if (options.deferSync) { ctx.streamPendingReindexFrom = Number.isInteger(ctx.streamPendingReindexFrom) ? Math.min(ctx.streamPendingReindexFrom, low) : low; } else { reindexStreamItems(ctx, low); } if (ctx.onlyOp) invalidateStreamLayout(ctx); else markStreamPrefixDirty(ctx, low); ctx.streamNeedsRepair = true; ensureStreamScaffold(ctx); if (!options.deferSync) scheduleStreamWindowSync(ctx); } function flushStreamPostBatch(ctx, syncNow = false, preserveViewportAnchor = false) { if (!ctx || !Number.isInteger(ctx.streamPendingReindexFrom)) return; const dirtyFrom = ctx.streamPendingReindexFrom; reindexStreamItems(ctx, dirtyFrom); ctx.streamPendingReindexFrom = null; if (ctx.onlyOp) invalidateStreamLayout(ctx); else markStreamPrefixDirty(ctx, dirtyFrom); if (syncNow) { const keepMountedWindow = preserveViewportAnchor && ctx.streamViewportAnchor && ctx.streamMountedNodes.size > 0 && ctx.streamWindowEnd > ctx.streamWindowStart; if (keepMountedWindow) { if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame); ctx.streamWindowFrame = 0; // Prepending changes every item index before scrollTop can be corrected. // Keep the anchored DOM mounted, restore it, then calculate the new window. mountStreamRange(ctx, ctx.streamWindowStart, ctx.streamWindowEnd); syncStreamWindow(ctx); } else { flushStreamWindow(ctx); } if (ctx.timeline) ctx.timeline.update(); } else { scheduleStreamWindowSync(ctx); } } function refreshStreamZebra(ctx, nodes = null) { if (!ctx || !ctx.commentsEl) return; if (ctx.onlyOp) streamLayoutItems(ctx); const targets = nodes || ctx.commentsEl.children; for (const node of targets) { if (!node.classList.contains('ldp-post')) continue; const item = ctx.streamItemMap && ctx.streamItemMap.get(streamPostNumber(node)); const zebraIndex = item ? (ctx.onlyOp ? item.layoutZebraIndex : item.zebraIndex) : -1; node.classList.toggle('ldp-zebra-alt', zebraIndex >= 0 && zebraIndex % 2 === 1); } } function applyNestedReplyDisplayPreference(ctx) { if (!ctx || !Array.isArray(ctx.streamItems)) return; const collapseChildren = PREFS.expandNestedRepliesByDefault !== true; const expandLeaf = PREFS.expandLeafNestedReplies === true; let reindexFrom = null; ctx.streamItems.forEach((item, index) => { const node = item && item.node; if (!node) return; const keepOnlyOpExpanded = !!(ctx.onlyOp && String(item.username || '') === String(ctx.op || '')); if (+(node.dataset.childReplyTotal || 0) > 0) { setChildRepliesCollapsed(node, keepOnlyOpExpanded ? false : collapseChildren); } const post = node._ldpPostData; const isLeafNestedReply = !!(post && node.classList.contains('ldp-reply-collapsible') && !post.hidden && !(+post.reply_count > 0)); if (!isLeafNestedReply) return; const repliesToTopicStarter = +(post.reply_to_post_number || node.dataset.replyToPostNumber || 0) === 1; const collapsed = !expandLeaf && !keepOnlyOpExpanded && !repliesToTopicStarter; setNestedReplyCollapsed(node, collapsed, { deferZebraSync: true }); if (item.zebraExcluded === collapsed) return; item.zebraExcluded = collapsed; reindexFrom = reindexFrom == null ? index : Math.min(reindexFrom, index); }); if (reindexFrom == null) return; reindexStreamItems(ctx, reindexFrom); if (ctx.onlyOp) invalidateStreamLayout(ctx); else markStreamPrefixDirty(ctx, reindexFrom); refreshStreamZebra(ctx); scheduleStreamWindowSync(ctx); } function findReplyListParent(postNumber, ctx) { const num = +postNumber; if (!num || !ctx) return null; const streamNode = getStreamPostNode(ctx, num); if (streamNode && streamNode.isConnected) return streamNode; return (ctx.commentsEl && ctx.commentsEl.querySelector(`:scope > .ldp-post[data-post-number="${num}"]`)) || null; } function rememberLatestReplyTime(ctx, post, options = {}) { const createdAt = String((post && post.created_at) || ''); const timestamp = Date.parse(createdAt); if (!ctx || !Number.isFinite(timestamp)) return; if (!options.force && timestamp <= +(ctx.latestReplyTimestamp || 0)) return; ctx.latestReplyAt = createdAt; ctx.latestReplyTimestamp = timestamp; if (ctx.timeline) ctx.timeline.update(); } function rememberPostData(ctx, post, parentHint = 0) { const postNumber = +(post && post.post_number || 0); if (!ctx || !postNumber) return 0; const knownParentNumber = +(ctx.replyParentByPost && ctx.replyParentByPost.get(postNumber) || 0); const parentNumber = +(post.reply_to_post_number || parentHint || knownParentNumber || 0); if (!parentNumber) return 0; if (!post.reply_to_post_number) post.reply_to_post_number = parentNumber; if (ctx.replyParentByPost) ctx.replyParentByPost.set(postNumber, parentNumber); let replies = ctx.directRepliesByParent.get(parentNumber); if (!replies) { replies = new Map(); ctx.directRepliesByParent.set(parentNumber, replies); } replies.set(postNumber, post); return parentNumber; } function attachPostBatch(posts, ctx, options = {}) { const batch = Array.isArray(posts) ? posts : []; if (!batch.length) return []; const batchOptions = { ...options, deferStreamSync: true }; const nodes = batch.map((post) => attachPost(post, ctx, batchOptions)).filter(Boolean); syncKnownBoostIdentities(ctx, batch.map((post) => post && post.username)); flushStreamPostBatch( ctx, options.syncStreamNow === true, options.preserveViewportAnchor === true, ); return nodes; } function attachPost(p, ctx, options = {}) { const rememberedParentNum = rememberPostData(ctx, p); rememberLatestReplyTime(ctx, p); const fromNestedList = !!options.nestedList; const parentNum = +(p.reply_to_post_number || rememberedParentNum || 0); const isNestedReply = !ctx.isPostVoting && !!parentNum; const renderAsReply = isNestedReply || (fromNestedList && !!parentNum); const preferredParentNode = options.preferredParentNode; const preferredParentMatches = preferredParentNode && preferredParentNode.isConnected && +preferredParentNode.dataset.postNumber === +parentNum; const logicalParentNode = fromNestedList && parentNum ? (preferredParentMatches ? preferredParentNode : findReplyListParent(parentNum, ctx)) : null; // A nested-list request always belongs to the requested post. Do not walk // that post's own parent chain: A -> B -> A conversations would otherwise // be reattached to an ancestor and create a visual reply cycle. const parentNode = fromNestedList ? logicalParentNode : null; if (fromNestedList) { const parentChildren = parentNode && parentNode.querySelector(':scope > .ldp-children'); const existingNode = parentChildren && parentChildren.querySelector( `:scope > .ldp-post[data-post-number="${+p.post_number}"]` ); if (existingNode) return existingNode; const node = renderPost(p, renderAsReply, ctx, { forceExpanded: true, nestedPreview: true, showParentJump: false, }); if (parentNode) { if (!options.deferNestedSync) parentChildren.appendChild(node); bindPostMediaLayoutSync(node, ctx); const streamNode = ctx.streamNodeMap.get(p.post_number); if (streamNode) { const streamIsNestedReply = +(p.reply_to_post_number || 0) > 0; const repliesToTopicStarter = +(p.reply_to_post_number || 0) === 1; const keepJumpTargetExpanded = streamNode.dataset.ldpJumpExpanded === '1'; if (streamIsNestedReply) { ensureStreamNestedReplyPresentation(streamNode, p.reply_to_post_number); } if (streamNode._ldpPostData?.hidden) { setNestedReplyCollapsed(streamNode, true); } else if (repliesToTopicStarter || !streamIsNestedReply || p.reply_count > 0) { setNestedReplyCollapsed(streamNode, false); if (p.reply_count > 0) { ctx.repliesIO.observe(streamNode); } } else { setNestedReplyCollapsed(streamNode, !keepJumpTargetExpanded); } } if (!options.deferNestedSync) syncChildControls(parentNode); } if (parentNode) ctx.tracker.observe(node); return node; } if (ctx.streamNodeMap.has(+p.post_number)) return ctx.streamNodeMap.get(+p.post_number) || null; const hiddenStreamPost = !!p.hidden; const collapsedStreamReply = hiddenStreamPost || !!(isNestedReply && parentNum !== 1 && !(p.reply_count > 0) && PREFS.expandLeafNestedReplies !== true); const node = renderPost(p, renderAsReply, ctx, { forceCollapsed: collapsedStreamReply, showParentJump: isNestedReply, deferContent: true, }); insertStreamPost(ctx, node, p.post_number, { deferSync: !!options.deferStreamSync, zebraExcluded: hiddenStreamPost || collapsedStreamReply, }); bindPostMediaLayoutSync(node, ctx); ctx.streamNodeMap.set(+p.post_number, node); ctx.tracker.observe(node); if (+p.post_number === 1) syncCommentsHeaderPlacement(ctx); return node; } let threadGroupGradientSerial = 0; function findParentPreviewLine(post) { const line = post && post.previousElementSibling; return line && line.classList.contains('ldp-floor-preview-link') ? line : null; } function clearCombinedParentPreviewLine(post) { const line = findParentPreviewLine(post); if (line) { line.classList.remove('ldp-floor-preview-link-grouped'); line.querySelector(':scope > .ldp-thread-group-svg')?.remove(); } if (post) { post.querySelector(':scope > .ldp-thread-group-svg')?.remove(); post.classList.remove('ldp-parent-preview-linked'); } } function syncGroupedParentPreviewLine(line, parent, posts) { const preview = line && line.previousElementSibling; if (!line || !line.classList.contains('open') || !preview || !preview.classList.contains('ldp-floor-preview')) return false; const parentAvatar = parent && parent.querySelector(':scope > .ldp-post-head .ldp-avatar'); const childAvatars = posts.map((post) => post.querySelector(':scope > .ldp-post-head .ldp-avatar')); const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar'); if (!parentAvatar || !previewAvatar || childAvatars.some((avatar) => !avatar)) return false; const layerRect = parent.getBoundingClientRect(); const previewRect = preview.getBoundingClientRect(); const previewAvatarRect = previewAvatar.getBoundingClientRect(); const points = [{ x: previewRect.left - layerRect.left, y: previewAvatarRect.top + previewAvatarRect.height / 2 - layerRect.top, }]; [parentAvatar, ...childAvatars].forEach((avatar) => { const rect = avatar.getBoundingClientRect(); points.push({ x: rect.left - layerRect.left, y: rect.top + rect.height / 2 - layerRect.top, }); }); if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) return false; const lineStyle = getComputedStyle(line); const radius = normalizeReplyLineRadius(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-radius'))); const lineWidth = normalizeLineWidth(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-width')), REPLY_LINE_WIDTH_DEFAULT); const linePadding = Math.ceil(lineWidth / 2) + 1; const branchGap = 5; const axisX = Math.min(...points.map((point) => point.x)) - radius; const minX = Math.floor(axisX - linePadding); const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + linePadding); const minY = Math.floor(points[0].y - linePadding); const maxY = Math.ceil(points[points.length - 1].y + linePadding); const width = Math.max(1, maxX - minX); const height = Math.max(1, maxY - minY); const localX = (value) => Math.round(value - minX); const localY = (value) => Math.round(value - minY); const trunkX = localX(axisX); const topY = localY(points[0].y); const bottomY = localY(points[points.length - 1].y - radius); const curveEndX = localX(axisX + radius); const topCurveStartY = localY(points[0].y + radius); const solidPathParts = [ `M ${trunkX} ${topCurveStartY} V ${bottomY}`, `M ${trunkX} ${topCurveStartY} Q ${trunkX} ${topY} ${curveEndX} ${topY}`, ]; const topBranchEndX = localX(points[0].x - branchGap); const branchPathParts = [{ d: `M ${curveEndX} ${topY} H ${topBranchEndX}`, reverse: topBranchEndX < curveEndX, startX: curveEndX, endX: topBranchEndX, y: topY, }]; points.slice(1).forEach((point) => { const y = localY(point.y); solidPathParts.push(`M ${trunkX} ${localY(point.y - radius)} Q ${trunkX} ${y} ${curveEndX} ${y}`); const branchEndX = localX(point.x - branchGap); branchPathParts.push({ d: `M ${curveEndX} ${y} H ${branchEndX}`, reverse: branchEndX < curveEndX, startX: curveEndX, endX: branchEndX, y, }); }); const pathParts = [{ d: solidPathParts.join(' '), branch: false, reverse: false }, ...branchPathParts.map((part) => ({ ...part, branch: true }))]; let svg = parent.querySelector(':scope > .ldp-thread-group-svg'); if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('ldp-thread-group-svg'); svg.setAttribute('aria-hidden', 'true'); parent.appendChild(svg); } svg.style.left = `${minX}px`; svg.style.top = `${minY}px`; svg.style.width = `${width}px`; svg.style.height = `${height}px`; svg.setAttribute('viewBox', `0 0 ${width} ${height}`); let defs = svg.querySelector(':scope > .ldp-thread-group-defs'); if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.classList.add('ldp-thread-group-defs'); svg.insertBefore(defs, svg.firstChild); } const gradients = Array.from(defs.querySelectorAll(':scope > .ldp-thread-group-gradient')); while (gradients.length < branchPathParts.length) { const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); gradient.classList.add('ldp-thread-group-gradient'); gradient.id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`; gradient.setAttribute('gradientUnits', 'userSpaceOnUse'); [0, 1, 1].forEach((offset, index) => { const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop.setAttribute('offset', String(offset)); stop.setAttribute('stop-color', 'currentColor'); stop.setAttribute('stop-opacity', index === 2 ? '0' : '1'); gradient.appendChild(stop); }); defs.appendChild(gradient); gradients.push(gradient); } while (gradients.length > branchPathParts.length) gradients.pop().remove(); const paths = Array.from(svg.querySelectorAll(':scope > .ldp-thread-group-path')); while (paths.length < pathParts.length) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.classList.add('ldp-thread-group-path'); svg.appendChild(path); paths.push(path); } while (paths.length > pathParts.length) paths.pop().remove(); let branchGradientIndex = 0; paths.forEach((path, index) => { const part = pathParts[index]; path.classList.toggle('ldp-thread-group-branch', part.branch); path.classList.toggle('ldp-thread-group-branch-reverse', part.reverse); path.setAttribute('d', part.d); if (!part.branch) { path.style.removeProperty('stroke'); return; } const gradient = gradients[branchGradientIndex++]; const branchLength = Math.max(1, Math.abs(part.endX - part.startX)); const fadeStart = Math.max(0, 1 - 12 / branchLength); gradient.setAttribute('x1', String(part.startX)); gradient.setAttribute('x2', String(part.endX)); gradient.setAttribute('y1', String(part.y)); gradient.setAttribute('y2', String(part.y)); gradient.children[1].setAttribute('offset', String(fadeStart)); path.style.stroke = `url(#${gradient.id})`; }); line.classList.add('ldp-floor-preview-link-grouped'); parent.classList.add('ldp-parent-preview-linked'); return true; } function syncNestedReplyBranches(children) { const posts = Array.from(children.children).filter((node) => node.classList.contains('ldp-post')); const parent = children.closest('.ldp-post'); const parentAvatar = parent && parent.querySelector(':scope > .ldp-post-head .ldp-avatar'); const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle'); const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle'); if (!parent || !parentAvatar || !posts.length || !children.isConnected) { if (parent) { parent.classList.remove('ldp-nested-branches-ready'); clearCombinedParentPreviewLine(parent); } return; } const branchGap = 5; let branchAxisX = null; let branchMetrics = []; let railTop = 0; let branchCanvasWidth = 0; let parentBranchWidth = 0; if (parentAvatar && posts.length) { const childrenRect = children.getBoundingClientRect(); const avatarRect = parentAvatar.getBoundingClientRect(); if (!childrenRect.width || !avatarRect.width || !avatarRect.height) { parent.classList.remove('ldp-nested-branches-ready'); clearCombinedParentPreviewLine(parent); return; } let axisX = Math.round(avatarRect.left - childrenRect.left - 15); let dropAnchorY = avatarRect.top + avatarRect.height / 2; const parentLine = findParentPreviewLine(parent); const parentAxisX = parentLine && parseFloat(parentLine.style.getPropertyValue('--ldp-parent-axis-left')); const parentPreviewLinked = !!parentLine && parentLine.classList.contains('open') && Number.isFinite(parentAxisX); if (parentPreviewLinked) { axisX = Math.round(parentLine.getBoundingClientRect().left + parentAxisX - childrenRect.left); dropAnchorY = avatarRect.top + avatarRect.height / 2; } const firstChildAvatar = posts[0] && posts[0].querySelector(':scope > .ldp-post-head .ldp-avatar'); const firstChildX = firstChildAvatar ? Math.round(firstChildAvatar.getBoundingClientRect().left - childrenRect.left) : 18; const firstBranchWidth = Math.max(0, firstChildX - axisX - branchGap); const parentHeadWidth = Math.max(0, avatarRect.left - childrenRect.left - axisX - branchGap); branchCanvasWidth = Math.max(1, Math.ceil(childrenRect.width)); parentBranchWidth = parentHeadWidth; const drop = Math.max(0, Math.round(childrenRect.top - dropAnchorY)); railTop = -drop; branchAxisX = axisX; branchMetrics = posts.map((post) => { const childAvatar = post.querySelector(':scope > .ldp-post-head .ldp-avatar'); if (!childAvatar) return null; const childAvatarRect = childAvatar.getBoundingClientRect(); const postRect = post.getBoundingClientRect(); return { branchY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - postRect.top)), branchWidth: Math.max(0, Math.round(childAvatarRect.left - childrenRect.left) - branchAxisX - branchGap), railY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - childrenRect.top)), }; }); const groupedReady = parentPreviewLinked && branchMetrics.length === posts.length && branchMetrics.every(Boolean) && syncGroupedParentPreviewLine(parentLine, parent, posts); if (!groupedReady) clearCombinedParentPreviewLine(parent); children.style.setProperty('--ldp-nested-axis-left', `${axisX}px`); children.style.setProperty('--ldp-nested-branch-width', `${firstBranchWidth}px`); children.style.setProperty('--ldp-nested-parent-drop', `${drop}px`); children.style.setProperty('--ldp-nested-parent-head-width', `${parentHeadWidth}px`); } else { children.style.removeProperty('--ldp-nested-axis-left'); children.style.removeProperty('--ldp-nested-branch-width'); children.style.removeProperty('--ldp-nested-parent-drop'); children.style.removeProperty('--ldp-nested-parent-head-width'); } const firstBranchMetric = branchMetrics.find(Boolean); const lastBranchMetric = branchMetrics[branchMetrics.length - 1]; if (railToggle && branchAxisX !== null && firstBranchMetric && lastBranchMetric) { railToggle.style.left = `${branchAxisX - 10}px`; railToggle.style.top = `${railTop}px`; railToggle.style.height = `${Math.max(24, lastBranchMetric.railY - 15 - railTop)}px`; } else if (railToggle) { railToggle.style.removeProperty('left'); railToggle.style.removeProperty('top'); railToggle.style.removeProperty('height'); } if (branchToggle && branchAxisX !== null && lastBranchMetric && branchCanvasWidth) { const radius = normalizeReplyLineRadius( parseFloat(getComputedStyle(children).getPropertyValue('--ldp-reply-line-radius')) ); const branchBottom = Math.max(railTop + 1, lastBranchMetric.railY + 10); const branchHeight = branchBottom - railTop; const pathParts = [ `M ${branchAxisX} ${railTop} H ${branchAxisX + parentBranchWidth}`, `M ${branchAxisX} ${railTop} V ${Math.max(railTop, lastBranchMetric.railY - radius)}`, ]; branchMetrics.forEach((metric) => { if (!metric) return; pathParts.push(`M ${branchAxisX} ${metric.railY - radius} Q ${branchAxisX} ${metric.railY} ${branchAxisX + radius} ${metric.railY} H ${branchAxisX + metric.branchWidth}`); }); branchToggle.style.left = '0px'; branchToggle.style.top = `${railTop}px`; branchToggle.style.width = `${branchCanvasWidth}px`; branchToggle.style.height = `${branchHeight}px`; branchToggle.setAttribute('viewBox', `0 ${railTop} ${branchCanvasWidth} ${branchHeight}`); branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.setAttribute('d', pathParts.join(' ')); } else if (branchToggle) { branchToggle.removeAttribute('viewBox'); branchToggle.style.removeProperty('left'); branchToggle.style.removeProperty('top'); branchToggle.style.removeProperty('width'); branchToggle.style.removeProperty('height'); branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.removeAttribute('d'); } posts.forEach((post, index) => { if (branchAxisX === null) { post.style.removeProperty('--ldp-nested-branch-y'); post.style.removeProperty('--ldp-nested-branch-width'); } else { const metric = branchMetrics[index]; if (metric) { post.style.setProperty('--ldp-nested-branch-y', `${metric.branchY}px`); post.style.setProperty('--ldp-nested-branch-width', `${metric.branchWidth}px`); } else { post.style.removeProperty('--ldp-nested-branch-y'); post.style.removeProperty('--ldp-nested-branch-width'); } } post.classList.toggle('ldp-nested-branch-last', index === posts.length - 1); post.classList.toggle('ldp-nested-branch-single', posts.length === 1); }); parent.classList.toggle('ldp-nested-branches-ready', branchMetrics.length === posts.length && branchMetrics.every(Boolean)); } const nestedBranchSyncFrames = new WeakMap(); function scheduleNestedBranchSync(post, ctx) { if (!post || !post.isConnected) return; const parentNum = +(post.dataset.replyToPostNumber || 0); const parent = parentNum && ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(parentNum); if (nestedBranchSyncFrames.has(post)) return; const run = () => { if (!post.isConnected) { nestedBranchSyncFrames.delete(post); return; } if (streamScrollIsActive(ctx, 100)) { nestedBranchSyncFrames.set(post, setTimeout(run, 110)); return; } nestedBranchSyncFrames.delete(post); syncChildControls(post); if (parent) syncChildControls(parent); }; const handle = streamScrollIsActive(ctx, 100) ? setTimeout(run, 110) : requestAnimationFrame(run); nestedBranchSyncFrames.set(post, handle); } function syncReaderSvgSymbols(content) { if (!content || !content.isConnected) return; const root = content.getRootNode(); if (!root || !root.host || typeof root.getElementById !== 'function') return; const missing = new Map(); content.querySelectorAll('svg use').forEach((use) => { const href = use.getAttribute('href') || use.getAttribute('xlink:href') || ''; if (!href.startsWith('#')) return; const id = href.slice(1); if (!id || root.getElementById(id) || missing.has(id)) return; const source = document.getElementById(id); if (source) missing.set(id, source); }); const pending = Array.from(missing.entries()); for (let index = 0; index < pending.length; index++) { const source = pending[index][1]; [source, ...source.querySelectorAll('*')].forEach((node) => { Array.from(node.attributes || []).forEach((attribute) => { const references = []; if ((attribute.name === 'href' || attribute.name === 'xlink:href') && attribute.value.startsWith('#')) { references.push(attribute.value.slice(1)); } for (const match of attribute.value.matchAll(/url\(\s*["']?#([^\s"')]+)["']?\s*\)/g)) references.push(match[1]); references.forEach((id) => { if (!id || root.getElementById(id) || missing.has(id)) return; const dependency = document.getElementById(id); if (!dependency) return; missing.set(id, dependency); pending.push([id, dependency]); }); }); }); } if (!missing.size) return; let sprite = root.querySelector(':scope > svg[data-ldp-svg-sprite]'); if (!sprite) { sprite = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sprite.dataset.ldpSvgSprite = '1'; sprite.setAttribute('aria-hidden', 'true'); sprite.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'; sprite.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'defs')); root.prepend(sprite); } const defs = sprite.querySelector('defs'); missing.forEach((source) => defs.appendChild(source.cloneNode(true))); } function retryableReaderImageUrl(source) { try { const url = new URL(String(source || ''), location.href); url.searchParams.set('_ldp_retry', String(Date.now())); return url.href; } catch (error) { return String(source || ''); } } function showReaderImageRetry(img, post, ctx) { if (!img || img.classList.contains('emoji')) return; let button = img._ldpImageRetryButton; if (!button || !button.isConnected) { button = document.createElement('button'); button.type = 'button'; button.className = 'ldp-image-retry'; button.innerHTML = `${icon('rotateCcw')}<span>重试图片</span>`; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (button.disabled) return; const source = String(img.dataset.ldpRetrySource || img.currentSrc || img.src || ''); if (!source) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.querySelector('span').textContent = '正在重试…'; const retryUrl = retryableReaderImageUrl(source); img.loading = 'eager'; img.srcset = retryUrl; img.src = retryUrl; scheduleNestedBranchSync(post, ctx); }); const link = img.closest('a'); if (link && post.contains(link)) link.insertAdjacentElement('afterend', button); else img.insertAdjacentElement('afterend', button); img._ldpImageRetryButton = button; } button.disabled = false; button.setAttribute('aria-busy', 'false'); button.querySelector('span').textContent = '重试图片'; } function clearReaderImageRetry(img) { const button = img && img._ldpImageRetryButton; if (button) button.remove(); if (img) img._ldpImageRetryButton = null; } function bindPostMediaLayoutSync(post, ctx) { const content = post.querySelector(':scope > .ldp-content'); if (!content) return; syncReaderSvgSymbols(post); content.querySelectorAll('iframe').forEach((frame) => { frame.loading = 'lazy'; }); content.querySelectorAll('video').forEach((video) => { if (!video.hasAttribute('preload')) video.preload = 'metadata'; }); activateReaderMedia(content); content.querySelectorAll('img').forEach((img) => { if (img.dataset.ldpLayoutSyncBound === '1') return; img.dataset.ldpLayoutSyncBound = '1'; if (!img.classList.contains('emoji')) { img.loading = 'lazy'; img.decoding = 'async'; if (!img.dataset.ldpRetrySource) { img.dataset.ldpRetrySource = absoluteImageUrl(img.currentSrc || img.src || img.getAttribute('src') || ''); } } const onLoad = () => { clearReaderImageRetry(img); scheduleNestedBranchSync(post, ctx); }; const onError = () => { showReaderImageRetry(img, post, ctx); scheduleNestedBranchSync(post, ctx); }; img.addEventListener('load', onLoad); img.addEventListener('error', onError); if (img.complete) { if (img.naturalWidth > 0) onLoad(); else onError(); } }); } function syncChildControls(post) { const children = post && post.querySelector(':scope > .ldp-children'); const btn = post && post.querySelector(':scope > .ldp-collapse-replies'); if (!children || !btn) return; const collapsed = post.classList.contains('ldp-children-collapsed'); let renderedCount = 0; for (const child of children.children) { if (child.classList.contains('ldp-post')) renderedCount++; } const totalCount = Math.max(+(post.dataset.childReplyTotal || 0), renderedCount); const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle'); const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle'); if (railToggle) { const railLabel = `点击收起 ${totalCount} 条回复`; railToggle.setAttribute('aria-label', railLabel); if (branchToggle) branchToggle.dataset.ldpTooltipLabel = railLabel; } if (renderedCount && post.classList.contains('ldp-reply') && !post._ldpPostData?.hidden) { setNestedReplyCollapsed(post, false); } if (!totalCount) { btn.classList.remove('show'); post.classList.remove('ldp-has-child-branches'); post.classList.remove('ldp-nested-branches-ready'); clearCombinedParentPreviewLine(post); delete btn.dataset.replyControlKey; return; } const showBranches = !!renderedCount && !collapsed; post.classList.toggle('ldp-has-child-branches', showBranches); if (!showBranches) { post.classList.remove('ldp-nested-branches-ready'); clearCombinedParentPreviewLine(post); } btn.classList.add('show'); const controlKey = `${collapsed ? 'collapsed' : 'open'}:${totalCount}`; if (btn.dataset.replyControlKey !== controlKey) { btn.dataset.replyControlKey = controlKey; btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}<span>${collapsed ? `展开 ${totalCount} 条回复` : `收起 ${totalCount} 条回复`}</span>`; } if (!collapsed) syncNestedReplyBranches(children); } function setChildRepliesCollapsed(post, collapsed) { if (!post) return; post.classList.toggle('ldp-children-collapsed', !!collapsed); syncChildControls(post); } /* ============ 7. 渲染单条 ============ */ function childReplyShellHtml(total) { return `<button class="ldp-btn ldp-collapse-replies" type="button" aria-expanded="true"></button> <div class="ldp-children"> <button class="ldp-nested-rail-toggle" type="button" aria-label="点击收起 ${total} 条回复"></button> <svg class="ldp-nested-branch-toggle" aria-hidden="true" focusable="false" data-ldp-tooltip-label="点击收起 ${total} 条回复"><path class="ldp-nested-branch-hit-path"></path></svg> <div class="ldp-sub-actions"> <button class="ldp-btn ldp-sub-page-btn ldp-sub-page-prev" type="button">${icon('chevronRight')}<span>上一页</span></button> <span class="ldp-sub-page-info"></span> <button class="ldp-btn ldp-sub-page-btn ldp-sub-page-next" type="button">${icon('chevronRight')}<span>下一页</span></button> </div> </div> <div class="ldp-sub-loading">加载楼中楼中…</div>`; } function readerPostIsRead(post, ctx) { if (!post) return false; if (post.read === true) return true; const postNumber = +(post.post_number || 0); const lastReadPostNumber = +(ctx && ctx.topicData && ctx.topicData.last_read_post_number || 0); return postNumber > 0 && lastReadPostNumber >= postNumber; } function postReadStateIconHtml(isRead) { return isRead ? icon('check', 'ldp-post-read-state-icon') : '<svg class="ldp-icon ldp-post-read-state-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5.5"/></svg>'; } function postReadStateHtml(isRead) { const state = isRead ? 'read' : 'unread'; const label = isRead ? '该楼层已读' : '该楼层未读'; return `<span class="ldp-post-read-state is-${state}" data-read-state="${state}" data-ldp-tooltip-label="${label}" role="img" aria-label="${label}">${postReadStateIconHtml(isRead)}</span>`; } function readerPostNodeInViewport(postNode, scrollRoot) { if (!postNode || !postNode.isConnected || !postNode.getClientRects().length) return false; const rect = postNode.getBoundingClientRect(); const viewport = scrollRoot && scrollRoot.getBoundingClientRect ? scrollRoot.getBoundingClientRect() : { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 }; return rect.bottom > viewport.top && rect.top < viewport.bottom && rect.right > viewport.left && rect.left < viewport.right; } function syncPostReadState(postNode, isRead, options = {}) { if (!postNode) return; const marker = postNode.querySelector(':scope > .ldp-post-head > .ldp-post-read-state'); if (!marker) return; const state = isRead ? 'read' : 'unread'; if (postNode._ldpPostData) postNode._ldpPostData.read = !!isRead; if (marker.dataset.readState === state || marker.dataset.readTransitionState === state) return; const label = isRead ? '该楼层已读' : '该楼层未读'; const cancelTransition = () => { if (marker._ldpReadStateSwapTimer) clearTimeout(marker._ldpReadStateSwapTimer); marker._ldpReadStateSwapTimer = 0; if (marker._ldpReadStateTimer) clearTimeout(marker._ldpReadStateTimer); marker._ldpReadStateTimer = 0; if (marker._ldpReadStateFinish) { marker.removeEventListener('animationend', marker._ldpReadStateFinish); } marker._ldpReadStateFinish = null; marker.classList.remove('is-confirming'); delete marker.dataset.readTransitionState; }; const applyVisual = () => { marker.dataset.readState = state; marker.classList.toggle('is-read', !!isRead); marker.classList.toggle('is-unread', !isRead); marker.setAttribute('aria-label', label); marker.dataset.ldpTooltipLabel = label; marker.removeAttribute('title'); marker.innerHTML = postReadStateIconHtml(isRead); marker.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }; const applyState = () => { cancelTransition(); if (marker.dataset.readState !== state) applyVisual(); }; const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (options.animate === true && isRead && !reduceMotion) { marker.dataset.readTransitionState = state; marker.classList.add('is-confirming'); const finish = () => { if (marker._ldpReadStateFinish !== finish) return; applyState(); }; marker._ldpReadStateFinish = finish; marker.addEventListener('animationend', finish, { once: true }); marker._ldpReadStateSwapTimer = setTimeout(() => { if (marker._ldpReadStateFinish !== finish) return; marker._ldpReadStateSwapTimer = 0; applyVisual(); }, 625); marker._ldpReadStateTimer = setTimeout(finish, 1450); return; } applyState(); } function syncConfirmedPostReadStateWhenVisible(node, ctx) { if (!node) return; const syncConfirmedState = () => syncPostReadState(node, true, { animate: true }); if (readerPostNodeInViewport(node, ctx && ctx.scrollRoot)) { syncConfirmedState(); } else if (ctx && ctx.tracker && typeof ctx.tracker.runWhenVisible === 'function') { ctx.tracker.runWhenVisible(node, syncConfirmedState); } else { syncPostReadState(node, true); } } function markReaderPostsRead(postNumbers, ctx) { const confirmed = new Set((postNumbers || []).map(Number).filter(Boolean)); if (!confirmed.size || !ctx) return; if (!ctx.confirmedReadPostNumbers) ctx.confirmedReadPostNumbers = new Set(); confirmed.forEach((postNumber) => ctx.confirmedReadPostNumbers.add(postNumber)); const persistedPosts = new Map(); const syncPostData = (post) => { if (!post || !confirmed.has(+(post.post_number || 0))) return; post.read = true; if (post.id) persistedPosts.set(+post.id, post); }; const syncNode = (node) => { const postNumber = +(node && node.dataset && node.dataset.postNumber || 0); if (!confirmed.has(postNumber)) return; const postData = node._ldpPostData; syncPostData(postData); syncConfirmedPostReadStateWhenVisible(node, ctx); }; if (ctx.modal) ctx.modal.querySelectorAll('.ldp-post').forEach(syncNode); if (ctx.streamNodeMap) { ctx.streamNodeMap.forEach((node) => { syncNode(node); if (node && node.querySelectorAll) node.querySelectorAll('.ldp-post').forEach(syncNode); }); } if (ctx.subReplyState) { ctx.subReplyState.forEach((state) => { if (state && state.nodeCache) state.nodeCache.forEach(syncNode); if (state && Array.isArray(state.all)) state.all.forEach(syncPostData); }); } if (ctx.directRepliesByParent) { ctx.directRepliesByParent.forEach((replies) => { if (replies) replies.forEach(syncPostData); }); } postsFromPayload(ctx.topicData).forEach(syncPostData); if (!persistedPosts.size) return; if (ctx.loader && typeof ctx.loader.persistFetchedPosts === 'function') { ctx.loader.persistFetchedPosts(Array.from(persistedPosts.values())); } else if (ctx.topicData) { setCachedTopicData(ctx.topicId, ctx.topicData); } } function renderPost(p, isReply, ctx, options = {}) { const avatar = avatarWithFlairHtml( persistentAvatarHtml( p.avatar_template, 48, 'ldp-avatar', p.name || p.username || '?' ), p ); const { count, acted, canAct } = likeInfo(p); const isOP = ctx.op && p.username === ctx.op; const isME = ME_USERNAME && p.username === ME_USERNAME; const time = fmtTime(p.created_at); const exactTime = fmtExactTime(p.created_at); const profileUrl = userProfileUrl(p.username); const userCardAttr = `data-user-card="${escAttr(p.username)}"`; const isTopicStarter = !isReply && !options.nestedPreview && +p.post_number === 1; const hiddenCollapsible = !!p.hidden && !options.nestedPreview; const deferContent = options.deferContent === true; const boostListHtml = deferContent ? (postHasBoostData(p.boosts) ? '<div class="ldp-boost-list ldp-boost-pending" aria-hidden="true"></div>' : '<div class="ldp-boost-list" hidden></div>') : renderBoostList(p.boosts, { canQuickBoost: canShowBoostButton(p), ctx }); const contentHtml = deferContent ? `<div class="ldp-content cooked ldp-content-pending" aria-busy="true" style="--ldp-content-placeholder-height:${postContentPlaceholderHeight(p)}px"></div>` : '<div class="ldp-content cooked"></div>'; const replyToPostNumber = +(p.reply_to_post_number || 0); const parentFloorHtml = options.showParentJump && isReply && replyToPostNumber ? `<button class="ldp-floor ldp-jump-parent" type="button" data-target-post-number="${replyToPostNumber}" aria-label="跳到父楼层 #${replyToPostNumber}">@#${replyToPostNumber}</button>` : ''; const currentFloorHtml = options.nestedPreview ? `<button class="ldp-floor ldp-jump-self" type="button" data-target-post-number="${p.post_number}" data-parent-post-number="${replyToPostNumber || ''}" aria-label="跳到楼层 #${p.post_number}">#${p.post_number}</button>` : `<span class="ldp-floor ldp-body-floor${parentFloorHtml ? ' ldp-current-floor' : ''}">#${p.post_number}</span>`; const deferConfirmedReadAnimation = !!(ctx && ctx.confirmedReadPostNumbers && ctx.confirmedReadPostNumbers.has(+(p.post_number || 0))); const readStateHtml = postReadStateHtml( deferConfirmedReadAnimation ? false : readerPostIsRead(p, ctx) ); const collapseNested = options.forceExpanded ? false : hiddenCollapsible || options.forceCollapsed ? true : shouldCollapseNestedReply(p, isReply, options); const allowChildReplies = !ctx.isPostVoting && !options.nestedPreview && p.reply_count > 0; const childRepliesHtml = allowChildReplies ? childReplyShellHtml(p.reply_count) : ''; const node = document.createElement('div'); node.className = 'ldp-post' + (isReply ? ' ldp-reply ldp-reply-collapsible' : '') + (hiddenCollapsible ? ' ldp-hidden-collapsible' : '') + (options.nestedPreview ? ' ldp-nested-preview' : '') + (collapseNested ? ' ldp-nested-collapsed' : '') + (allowChildReplies && (isTopicStarter || PREFS.expandNestedRepliesByDefault !== true) ? ' ldp-children-collapsed' : ''); node.dataset.postId = p.id; node.dataset.postNumber = p.post_number; node.dataset.username = p.username || ''; node.dataset.createdAt = p.created_at || ''; node.dataset.ldpContentHydrated = deferContent ? '0' : '1'; node._ldpPostData = p; node._ldpReaderContext = ctx; if (replyToPostNumber) node.dataset.replyToPostNumber = replyToPostNumber; if (allowChildReplies) node.dataset.childReplyTotal = String(p.reply_count); node.innerHTML = ` ${isReply || hiddenCollapsible ? '<button class="ldp-btn ldp-nested-toggle" type="button"></button>' : ''} <div class="ldp-post-head"> ${avatar ? `<button class="ldp-user-link ldp-avatar-link" type="button" data-user-avatar-preview data-tooltip ${userCardAttr} aria-label="查看 ${escAttr(p.name || p.username)} 的头像原图">${avatar}</button>` : ''} <a class="ldp-user-link ldp-author" href="${profileUrl}" target="_blank" rel="noopener" ${userCardAttr}>${esc(p.name || p.username)}</a> ${isME ? '<span class="ldp-me">ME</span>' : ''} <a class="ldp-user-link ldp-user" href="${profileUrl}" target="_blank" rel="noopener" ${userCardAttr}>@${esc(p.username)}</a> ${isOP ? '<span class="ldp-op">OP</span>' : ''} ${time ? `<span class="ldp-time"${exactTime ? ` data-exact-time="${escAttr(exactTime)}"` : ''}><span class="ldp-time-relative">· ${esc(time)}</span></span>` : ''} ${p.hidden ? '<span class="ldp-special-badge ldp-hidden-badge warn">已隐藏</span>' : ''} ${parentFloorHtml}${currentFloorHtml}${readStateHtml} </div> ${contentHtml} <div class="ldp-reactions"> <div class="ldp-actions"> <button class="ldp-btn ldp-like ${acted ? 'liked' : ''}" data-acted="${acted ? '1' : '0'}" aria-label="${acted ? '取消点赞' : '点赞'}" ${canAct || acted ? '' : 'disabled'}> ${icon('heart')} <span class="ldp-like-count">${count}</span> </button> ${compactPostActionsHtml(p, ctx)} </div> </div> ${boostListHtml} ${childRepliesHtml} ${isTopicStarter ? topicFooterActionsHtml(p, ctx) : ''} `; if (deferConfirmedReadAnimation) syncConfirmedPostReadStateWhenVisible(node, ctx); if (!deferContent) { const content = node.querySelector(':scope > .ldp-content'); if (content) setPostCookedContent(content, p.cooked, p.link_counts); renderPostPolls(node, ctx); renderPostSpecialContent(node, ctx); } prepareReaderMedia(node); hydratePersistentAvatarImages(node); syncReaderSvgSymbols(node); if (isTopicStarter) syncTopicActionControls(ctx); syncPostActionControls(node, ctx); if (isReply || hiddenCollapsible) setNestedReplyCollapsed(node, collapseNested); return node; } /* ============ 8. 回复框 ============ */ function lookupDiscourse(name) { const candidates = Array.from(new Set([HOST_PAGE_WINDOW, window].filter(Boolean))); for (const candidate of candidates) { try { if (!candidate.Discourse || !candidate.Discourse.__container__ || typeof candidate.Discourse.__container__.lookup !== 'function') continue; const item = candidate.Discourse.__container__.lookup(name); if (item) return item; } catch (e) {} } return null; } function tryRequire(name) { const candidates = Array.from(new Set([HOST_PAGE_WINDOW, window].filter(Boolean))); for (const candidate of candidates) { try { const req = candidate.require || candidate.requirejs; if (typeof req !== 'function') continue; const module = req.call(candidate, name); if (module) return module; } catch (e) {} } return null; } function navigateDiscourseHostRoute(href) { let route; try { const url = new URL(String(href || ''), location.href); if (url.origin !== location.origin) return false; route = `${url.pathname}${url.search}${url.hash}`; } catch (e) { return false; } const discourseUrlModule = tryRequire('discourse/lib/url'); const routeOwners = Array.from(new Set([ discourseUrlModule, discourseUrlModule && discourseUrlModule.default, HOST_PAGE_WINDOW.DiscourseURL, HOST_PAGE_WINDOW.Discourse && HOST_PAGE_WINDOW.Discourse.URL, ].filter(Boolean))); for (const owner of routeOwners) { if (typeof owner.routeTo !== 'function') continue; try { const transition = owner.routeTo(route); if (transition && typeof transition.catch === 'function') { transition.catch(() => {}); } return true; } catch (e) {} } return false; } function openEmbeddedReaderLinkInHost(event, link, readerWorkspace) { if (!link || !readerWorkspace || !readerWorkspace.isEmbedded() || link.hasAttribute('download') || link.matches('a.lightbox') || event.button && event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false; let url; try { url = new URL(link.getAttribute('href') || link.href || '', location.href); } catch (error) { return false; } if (!/^https?:$/i.test(url.protocol) || url.origin !== location.origin) return false; url.searchParams.delete(NATIVE_BYPASS_PARAM); const topicRoute = extractTopicRouteFromUrl(url.href); if (topicRoute) return false; const previousSuppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID; event.preventDefault(); event.stopPropagation(); if (navigateDiscourseHostRoute(url.href)) return true; AUTO_OPEN_SUPPRESSED_TOPIC_ID = previousSuppressedTopicId; showSelectionToast('LINUX DO 站内导航暂不可用,请稍后重试'); return true; } function refreshDiscourseHostRoute() { const router = lookupDiscourse('service:router'); if (!router || typeof router.refresh !== 'function') return false; try { const transition = router.refresh(); if (transition && typeof transition.catch === 'function') { transition.catch(() => {}); } return true; } catch (e) { return false; } } function getUserCardUsername(link) { const username = link && (link.dataset.userCard || link.getAttribute('data-user-card')); return cleanUsernameValue(username); } function openDiscourseActionRoute(href) { if (navigateDiscourseHostRoute(href)) return true; try { location.assign(new URL(href, BASE).href); return true; } catch (error) { return false; } } async function openDirectPrivateMessage(username, ctx) { const href = `/new-message?username=${encodeURIComponent(username)}`; if (!navigateDiscourseHostRoute(href)) return openDiscourseActionRoute(href); if (!await waitForNativeComposerPopup(ctx)) return false; return !!(ctx && ctx.nativeComposerWindow && ctx.nativeComposerWindow.open(document.querySelector('#reply-control'))); } async function openReaderEndorsementDialog(ctx, user) { const username = String(user && user.username || '').trim(); if (!ctx || !username) return false; const data = await fetchJSON(`${BASE}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`, { key: `user-endorsements:${username}`, priority: POST_REQUEST_PRIORITY.userCard, }); const categories = Array.isArray(data && data.categories) ? data.categories : []; const remaining = Number(data && data.extras && data.extras.remaining_endorsements); const existingIds = new Set((Array.isArray(user.category_expert_endorsements) ? user.category_expert_endorsements : []).map((item) => Number(item && item.category_id)).filter(Number.isFinite)); if (!categories.length) throw new Error('当前没有可认可的类别'); const optionsHtml = categories.map((category) => { const categoryId = Number(category && category.id); const existing = existingIds.has(categoryId); return `<label class="ldp-reader-action-option"> <input type="checkbox" name="category_id" value="${categoryId}"${existing ? ' checked disabled' : ''}> <span class="ldp-reader-action-option-copy"><strong>${esc(category && category.name || `类别 ${categoryId}`)}</strong> <small>${existing ? '已经认可' : '选择后将认可该用户为此类别的专家'}</small></span> </label>`; }).join(''); return !!openReaderActionDialog(ctx, { title: `认可 @${username}`, intro: Number.isFinite(remaining) ? `今天还可新增 ${remaining} 次认可。` : '选择要认可的专家类别。', submitLabel: '确认认可', initialFocus: 'input[name="category_id"]:not(:disabled)', bodyHtml: `<div class="ldp-reader-action-options">${optionsHtml}</div>`, onSubmit: async (formData) => { if (Number.isFinite(remaining) && remaining < 1) throw new Error('今天的认可次数已用完'); const addedIds = formData.getAll('category_id').map(Number) .filter((categoryId) => Number.isFinite(categoryId) && !existingIds.has(categoryId)); if (!addedIds.length) throw new Error('请选择一个尚未认可的类别'); const categoryIds = Array.from(new Set([...existingIds, ...addedIds])); const response = await apiSend( `${BASE}/category-experts/endorse/${encodeURIComponent(username)}.json`, 'PUT', { categoryIds } ); user.category_expert_endorsements = Array.isArray(response && response.category_expert_endorsements) ? response.category_expert_endorsements : user.category_expert_endorsements; setCachedUserCard(username, user, 0); return '认可已提交'; }, }); } function userNotificationLevel(user) { if (user && user.ignored) return 'ignore'; if (user && user.muted) return 'mute'; return 'normal'; } const USER_NOTIFICATION_LEVEL_LABELS = { normal: '常规', mute: '免打扰', ignore: '忽略', }; async function saveReaderUserNotificationLevel(username, user, level, extraParams = {}) { if (!username || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) { throw new Error('无效的消息设置'); } await apiSend(`${BASE}/u/${encodeURIComponent(username)}/notification_level.json`, 'PUT', { notification_level: level, ...extraParams, }); if (user) { user.muted = level === 'mute'; user.ignored = level === 'ignore'; setCachedUserCard(username, user, 0); } return USER_NOTIFICATION_LEVEL_LABELS[level]; } function openReaderIgnoreDurationDialog(ctx, username, user) { const durations = [ { days: 1, label: '1 天' }, { days: 7, label: '1 周' }, { days: 30, label: '1 个月' }, { days: 120, label: '4 个月' }, { days: 365, label: '1 年' }, ]; return openReaderActionDialog(ctx, { title: `忽略 @${username}`, intro: 'Discourse 要求为“忽略”设置截止时间;到期后会自动恢复为常规。', submitLabel: '确认忽略', initialFocus: 'select[name="ignore_days"]', bodyHtml: `<label class="ldp-reader-action-field"><span>忽略期限</span> <select class="ldp-reader-select" name="ignore_days">${durations.map((item) => `<option value="${item.days}"${item.days === 30 ? ' selected' : ''}>${item.label}</option>`).join('')}</select> </label>`, onSubmit: async (formData) => { const days = Number(formData.get('ignore_days')); if (!durations.some((item) => item.days === days)) throw new Error('请选择有效的忽略期限'); const expiringAt = new Date(Date.now() + days * 86400000).toISOString(); await saveReaderUserNotificationLevel(username, user, 'ignore', { expiring_at: expiringAt }); return `已忽略 @${username}`; }, }); } async function runDirectUserCardAction(action, username, user, ctx) { if (!username || !['message', 'endorse', 'follow'].includes(action)) { return { ok: false, reason: 'invalid-action' }; } try { if (action === 'message') { const opened = await openDirectPrivateMessage(username, ctx); return { ok: opened, reason: opened ? '' : 'route-unavailable' }; } if (action === 'endorse') { const opened = await openReaderEndorsementDialog(ctx, user || { username }); return { ok: opened, reason: opened ? '' : 'endorsement-unavailable' }; } if (action === 'follow') { if (user && user.can_follow === false && !user.is_followed) { return { ok: false, reason: 'follow-not-allowed' }; } const followed = !!(user && user.is_followed); await apiSend(`${BASE}/follow/${encodeURIComponent(username)}.json`, followed ? 'DELETE' : 'PUT'); if (user) { const totalFollowers = Number(user.total_followers); user.is_followed = !followed; if (Number.isFinite(totalFollowers)) user.total_followers = Math.max(0, totalFollowers + (followed ? -1 : 1)); setCachedUserCard(username, user, 0); } invalidateUserFollowList(username, 'followers'); invalidateUserFollowList(ME_USERNAME, 'following'); return { ok: true, reason: '', keepOpen: true, followed: !followed }; } } catch (error) { console.warn('[LINUX DO reader] direct user card action failed', { action, error }); return { ok: false, reason: 'action-failed', error }; } } function emitUserCardUpdate(entry, user, phase) { if (!entry || !entry.listeners) return; entry.listeners.forEach((listener) => { try { listener(user, phase); } catch (e) {} }); } function userTrustLevel(user) { const rawLevel = user && user.trust_level; const direct = Number(rawLevel); if (rawLevel != null && rawLevel !== '' && Number.isFinite(direct)) return direct; const labels = [ user && user.title, user && user.flair_name, user && user.primary_group_name, ...(Array.isArray(user && user.groups) ? user.groups.flatMap((group) => [group && group.name, group && group.full_name, group && group.title]) : []), ]; for (const label of labels) { const match = String(label || '').match(/(?:^|[,,\s])lv\s*([0-9]+)(?:\b|$)/i); if (match) return Number(match[1]); } return null; } function userDetailsChecked(user, detail) { const checkedAt = Number(user && user[`_ldp_${detail}_checked_at`] || 0); return checkedAt > 0 && Date.now() - checkedAt < USER_CARD_CACHE_FRESH_FOR; } function fetchUserJSON(path, key, options = null) { const requestOptions = { key, priority: POST_REQUEST_PRIORITY.userCard }; if (options) Object.assign(requestOptions, { cacheMode: options.cacheMode, allowStaleOnError: options.allowStaleOnError, }); return fetchJSON(`${BASE}${path}`, requestOptions); } async function fetchUserProfileDetails(username, options = {}) { try { const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}.json`, `user-profile:${username}`, options); return data && (data.user || data); } catch (error) { if (shouldStopReaderFallback(error)) throw error; const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}/card.json`, `user-card:${username}`, options); return data && (data.user || data); } } function uniqueUserBadges(source) { const unique = new Map(); (Array.isArray(source) ? source : []).forEach((badge) => { if (!badge) return; const badgeId = Number(badge.id || badge.badge_id) || 0; const name = String(badge.name || '').trim(); const key = badgeId ? `id:${badgeId}` : `name:${name.toLocaleLowerCase()}`; if (!badgeId && !name) return; const previous = unique.get(key); const grantedAt = Date.parse(badge.granted_at || '') || 0; const previousGrantedAt = Date.parse(previous && previous.granted_at || '') || 0; if (!previous || grantedAt >= previousGrantedAt) unique.set(key, badge); }); return Array.from(unique.values()); } async function fetchUserBadges(username) { const data = await fetchUserJSON(`/user-badges/${encodeURIComponent(username)}.json`, `user-badges:${username}`); const badgesById = new Map((Array.isArray(data && data.badges) ? data.badges : []) .map((badge) => [Number(badge && badge.id), badge])); return uniqueUserBadges((Array.isArray(data && data.user_badges) ? data.user_badges : []).map((grant) => { const badge = badgesById.get(Number(grant && grant.badge_id)) || {}; return Object.assign({}, badge, grant, { id: Number(grant && grant.badge_id || badge.id) || 0, name: String(badge.name || grant && grant.name || '').trim(), }); }).filter((badge) => badge.id && badge.name && badge.enabled !== false)); } function normalizeUserFollowList(data, type) { const source = Array.isArray(data) ? data : Array.isArray(data && data.users) ? data.users : Array.isArray(data && data[type]) ? data[type] : []; return source.map((entry) => { const user = entry && (entry.user || entry); const username = cleanUsernameValue(user && user.username); if (!username) return null; const name = String(user.name || username).trim(); return { username, name, avatar_template: String(user.avatar_template || ''), flair_name: user.flair_name, flair_url: user.flair_url, flair_bg_color: user.flair_bg_color, flair_color: user.flair_color, searchText: pinyinSearchForms(`${username} ${name}`).join(' '), }; }).filter(Boolean); } function cachedUserFollowList(key) { const cached = USER_FOLLOW_LIST_CACHE.get(key); if (!cached || Date.now() - cached.at > USER_FOLLOW_LIST_CACHE_FRESH_FOR) { USER_FOLLOW_LIST_CACHE.delete(key); return null; } USER_FOLLOW_LIST_CACHE.delete(key); USER_FOLLOW_LIST_CACHE.set(key, cached); return cached.users; } function invalidateUserFollowList(username, type) { username = cleanUsernameValue(username); if (!username || !['following', 'followers'].includes(type)) return; USER_FOLLOW_LIST_CACHE.delete(authScopedCacheKey(`${username}:${type}`)); const policy = responseCachePolicy(`${BASE}/u/${encodeURIComponent(username)}/follow/${type}`); if (policy) void deleteStoredResponseCacheEntry(policy.id); } async function fetchUserFollowList(username, type) { username = cleanUsernameValue(username); if (!username || !['following', 'followers'].includes(type)) return []; const cacheKey = authScopedCacheKey(`${username}:${type}`); const cached = cachedUserFollowList(cacheKey); if (cached) return cached; if (USER_FOLLOW_LIST_REQUESTS.has(cacheKey)) return USER_FOLLOW_LIST_REQUESTS.get(cacheKey); const request = fetchUserJSON( `/u/${encodeURIComponent(username)}/follow/${type}`, `user-${type}:${username}` ).then((data) => { const users = normalizeUserFollowList(data, type); USER_FOLLOW_LIST_CACHE.delete(cacheKey); USER_FOLLOW_LIST_CACHE.set(cacheKey, { users, at: Date.now() }); while (USER_FOLLOW_LIST_CACHE.size > USER_FOLLOW_LIST_CACHE_MAX_ENTRIES) { USER_FOLLOW_LIST_CACHE.delete(USER_FOLLOW_LIST_CACHE.keys().next().value); } return users; }).finally(() => USER_FOLLOW_LIST_REQUESTS.delete(cacheKey)); USER_FOLLOW_LIST_REQUESTS.set(cacheKey, request); return request; } async function fetchUserCard(username, onUpdate) { username = cleanUsernameValue(username); if (!username) return null; const existing = USER_CARD_REQUESTS.get(username); if (existing && !existing.cancelled) { if (typeof onUpdate === 'function') { existing.listeners.add(onUpdate); if (existing.basic) onUpdate(existing.basic, 'stats'); } return existing.promise; } if (existing) USER_CARD_REQUESTS.delete(username); const cached = getCachedUserCard(username); const profileChecked = cached && userDetailsChecked(cached, 'profile'); const summaryChecked = cached && userDetailsChecked(cached, 'summary'); const badgesChecked = cached && userDetailsChecked(cached, 'badges'); if (cached && profileChecked && summaryChecked && badgesChecked) { if (typeof onUpdate === 'function') onUpdate(cached, 'ready'); return cached; } if (cached && typeof onUpdate === 'function') onUpdate(cached, 'stats'); const profilePromise = profileChecked ? Promise.resolve(cached) : fetchUserProfileDetails(username); const summaryPromise = summaryChecked ? Promise.resolve(cached.user_summary) : fetchUserSummary(username); const badgesPromise = badgesChecked ? Promise.resolve(Array.isArray(cached.user_badges) ? cached.user_badges : []) : fetchUserBadges(username); const supplementalPromise = Promise.allSettled([summaryPromise, badgesPromise]); const entry = { listeners: new Set(), basic: null, promise: null, cancelled: false }; if (typeof onUpdate === 'function') entry.listeners.add(onUpdate); const request = (async () => { const profile = await profilePromise; const user = Object.assign({}, cached || {}, profile || {}, { _ldp_profile_checked_at: profileChecked ? cached._ldp_profile_checked_at : Date.now(), }); entry.basic = user; setCachedUserCard(username, user); emitUserCardUpdate(entry, user, 'stats'); if (entry.cancelled) return user; const [summaryResult, badgesResult] = await supplementalPromise; if (badgesResult.status === 'fulfilled') { user.user_badges = badgesResult.value; } if (summaryResult.status === 'rejected') throw summaryResult.reason; user.user_summary = summaryResult.value; user._ldp_summary_checked_at = Date.now(); setCachedUserCard(username, user, 0); emitUserCardUpdate(entry, user, 'ready'); return user; })().finally(() => { if (USER_CARD_REQUESTS.get(username) === entry) USER_CARD_REQUESTS.delete(username); }); entry.promise = request; USER_CARD_REQUESTS.set(username, entry); return request; } function cancelQueuedUserCardRequests(username, scheduler = ACTIVE_READER_REQUEST_SCHEDULER) { username = cleanUsernameValue(username); if (!username) return; const entry = USER_CARD_REQUESTS.get(username); if (entry) entry.cancelled = true; if (!scheduler) return; [ `user-profile:${username}`, `user-card:${username}`, `user-summary:${username}`, `user-directory:${username}`, `user-badges:${username}`, `user-following:${username}`, `user-followers:${username}`, ].forEach((key) => scheduler.cancelQueued(key)); } async function fetchUserSummary(username, options = {}) { username = cleanUsernameValue(username); if (!username) return null; let summary = null; try { const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}/summary.json`, `user-summary:${username}`, options); summary = data && (data.user_summary || data.summary || data); } catch (e) { if (shouldStopReaderFallback(e)) throw e; summary = null; } const hasStats = summary && ['post_count', 'posts_count', 'likes_received', 'likes_given', 'topic_count', 'topics_entered'] .some((key) => summary[key] != null && summary[key] !== ''); const directoryStats = hasStats ? null : await fetchUserDirectoryStats(username, options); return Object.assign({}, directoryStats || {}, summary || {}); } async function fetchUserDirectoryStats(username, options = {}) { username = cleanUsernameValue(username); if (!username) return null; try { const qs = new URLSearchParams(); qs.set('period', 'all'); qs.set('order', 'likes_received'); qs.set('username', username); const data = await fetchUserJSON(`/directory_items.json?${qs}`, `user-directory:${username}`, options); const item = data && Array.isArray(data.directory_items) && data.directory_items[0]; if (!item) return null; return { post_count: item.post_count, likes_received: item.likes_received, likes_given: item.likes_given, topic_count: item.topic_count, }; } catch (e) { if (shouldStopReaderFallback(e)) throw e; return null; } } async function fetchSettingsUserInfo(username, force = false) { username = cleanUsernameValue(username); if (!username) return null; const cached = getCachedUserCard(username); if (!force && cached && userDetailsChecked(cached, 'profile') && userDetailsChecked(cached, 'summary')) return cached; const requestOptions = force ? { cacheMode: 'refresh', allowStaleOnError: false } : { cacheMode: 'default' }; const [profile, summary] = await Promise.all([ fetchUserProfileDetails(username, requestOptions), fetchUserSummary(username, requestOptions), ]); const checkedAt = Date.now(); const user = Object.assign({}, cached || {}, profile || {}, { username, user_summary: summary, _ldp_profile_checked_at: checkedAt, _ldp_summary_checked_at: checkedAt, _ldp_user_info_checked_at: checkedAt, }); setCachedUserCard(username, user, 0); return user; } function getFallbackUserCard() { let card = readerPortalQuery('.ldp-user-card-fallback:not(.ldp-user-card-follow-preview)'); if (!card) { card = document.createElement('div'); card.className = 'ldp-user-card-fallback'; appendReaderPortalNode(card); } return card; } function getUserCardFollowPreview() { let card = readerPortalQuery('.ldp-user-card-follow-preview'); if (card) return card; card = document.createElement('div'); card.className = 'ldp-user-card-fallback ldp-user-card-follow-preview'; appendReaderPortalNode(card); return card; } function getUserCardFollowPanel() { let panel = readerPortalQuery('.ldp-user-card-follow-panel'); if (panel) return panel; panel = document.createElement('section'); panel.id = 'ldp-user-card-follow-panel'; panel.className = 'ldp-user-card-follow-panel'; panel.setAttribute('aria-label', '关注人员列表'); panel.hidden = true; panel.innerHTML = ` <header><strong class="ldp-user-card-follow-title"></strong> <button type="button" data-user-card-follow-close aria-label="关闭关注人员列表">×</button></header> <nav class="ldp-user-card-breadcrumbs" aria-label="用户卡层级" hidden></nav> <label class="ldp-user-card-follow-search"><span>检索</span> <input type="search" autocomplete="off" spellcheck="false" placeholder="昵称、用户名或拼音"> </label> <div class="ldp-user-card-follow-summary" aria-live="polite"></div> <div class="ldp-user-card-follow-list" role="list"></div> <nav class="ldp-user-card-follow-pagination" aria-label="人员列表分页" hidden> <button type="button" data-user-card-follow-page="previous">上一页</button> <span aria-live="polite"></span> <button type="button" data-user-card-follow-page="next">下一页</button> </nav>`; appendReaderPortalNode(panel); return panel; } function hideFallbackUserCard() { const card = readerPortalQuery('.ldp-user-card-fallback:not(.ldp-user-card-follow-preview)'); if (card) card.classList.remove('open'); } function fallbackUserCardOpen() { return !!readerPortalQuery('.ldp-user-card-fallback.open:not(.ldp-user-card-follow-preview)'); } function positionFallbackUserCardNotificationMenu(card) { const menu = card && card.querySelector('.ldp-user-card-notification-menu:not([hidden])'); const actions = card && card.querySelector('.ldp-user-card-actions'); if (!menu || !actions) return; const margin = 10; const gap = 6; const rect = actions.getBoundingClientRect(); const width = Math.min(rect.width, window.innerWidth - margin * 2); menu.style.width = `${Math.round(width)}px`; const height = menu.offsetHeight; const spaceAbove = rect.top - margin - gap; const spaceBelow = window.innerHeight - rect.bottom - margin - gap; const opensAbove = spaceBelow < height && spaceAbove > spaceBelow; const preferredTop = opensAbove ? rect.top - height - gap : rect.bottom + gap; const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin)); const top = Math.max(margin, Math.min(preferredTop, window.innerHeight - height - margin)); menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; } function positionFallbackUserCard(card, anchor) { card.style.removeProperty('max-height'); const rect = anchor.getBoundingClientRect(); const margin = 10; const width = card.offsetWidth || 300; const height = Math.min(card.offsetHeight || 220, window.innerHeight - margin * 2); const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin)); let top = rect.bottom + 8; if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 8); card.style.left = `${Math.round(left)}px`; card.style.top = `${Math.round(top)}px`; positionFallbackUserCardNotificationMenu(card); } function positionUserCardFollowPanel(panel, card) { if (!panel || panel.hidden || !card) return; const margin = 10; const gap = 8; panel.style.removeProperty('max-height'); card.style.removeProperty('max-height'); const cardRect = card.getBoundingClientRect(); const width = panel.offsetWidth || 320; const height = Math.min(panel.offsetHeight || 220, window.innerHeight - margin * 2); const right = cardRect.right + gap; const left = cardRect.left - width - gap; const preferredLeft = right + width <= window.innerWidth - margin ? right : left; const fallbackLeft = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin)); const fallbackTop = Math.max(margin, Math.min(cardRect.top, window.innerHeight - height - margin)); const avatarViewer = readerPortalQuery('.ldp-avatar-viewer'); const viewerRect = avatarViewer && avatarViewer.isConnected && avatarViewer._ldpAnchorCard === card ? avatarViewer.getBoundingClientRect() : null; const overlaps = (first, second) => first.left < second.right + gap && first.right + gap > second.left && first.top < second.bottom + gap && first.bottom + gap > second.top; const fallbackRect = { left: fallbackLeft, top: fallbackTop, right: fallbackLeft + width, bottom: fallbackTop + height, }; if (!viewerRect || !overlaps(fallbackRect, viewerRect)) { panel.style.left = `${Math.round(fallbackLeft)}px`; panel.style.top = `${Math.round(fallbackTop)}px`; return; } const minimumPanelHeight = Math.min(height, 150); const freeRegions = [ { left: margin, top: margin, right: viewerRect.left - gap, bottom: window.innerHeight - margin }, { left: viewerRect.right + gap, top: margin, right: window.innerWidth - margin, bottom: window.innerHeight - margin }, { left: margin, top: margin, right: window.innerWidth - margin, bottom: viewerRect.top - gap }, { left: margin, top: viewerRect.bottom + gap, right: window.innerWidth - margin, bottom: window.innerHeight - margin }, ].filter((region) => region.right - region.left >= width && region.bottom - region.top >= minimumPanelHeight); const placements = freeRegions.flatMap((region) => { const availableHeight = region.bottom - region.top; const placedHeight = Math.min(height, availableHeight); const candidateLefts = new Set([ Math.max(region.left, Math.min(cardRect.left, region.right - width)), region.left, region.right - width, ]); const candidateTops = new Set([ Math.max(region.top, Math.min(cardRect.top, region.bottom - placedHeight)), region.top, region.bottom - placedHeight, ]); return Array.from(candidateLefts).flatMap((placedLeft) => Array.from(candidateTops).map((placedTop) => { const rect = { left: placedLeft, top: placedTop, right: placedLeft + width, bottom: placedTop + placedHeight, }; return { left: placedLeft, top: placedTop, height: placedHeight, rect, score: Math.abs(placedLeft - fallbackLeft) + Math.abs(placedTop - fallbackTop) + (height - placedHeight) * 3, }; })); }).filter((placement) => !overlaps(placement.rect, cardRect)) .sort((first, second) => first.score - second.score); if (placements.length) { const placement = placements[0]; if (placement.height < height) panel.style.maxHeight = `${Math.floor(placement.height)}px`; panel.style.left = `${Math.round(placement.left)}px`; panel.style.top = `${Math.round(placement.top)}px`; return; } const cardWidth = cardRect.width; const cardHeight = Math.min(cardRect.height, window.innerHeight - margin * 2); const minimumCardHeight = Math.min(cardHeight, 180); const stackedRegions = freeRegions.filter((region) => region.right - region.left >= Math.max(cardWidth, width) && region.bottom - region.top >= minimumCardHeight + gap + minimumPanelHeight ).map((region) => { const availableHeight = region.bottom - region.top - gap; const desiredTotal = Math.max(1, cardHeight + height); const placedCardHeight = Math.max(minimumCardHeight, Math.min( availableHeight - minimumPanelHeight, Math.round(availableHeight * cardHeight / desiredTotal) )); const placedPanelHeight = Math.min(height, availableHeight - placedCardHeight); const groupHeight = placedCardHeight + gap + placedPanelHeight; const groupTop = Math.max(region.top, Math.min(cardRect.top, region.bottom - groupHeight)); const placedCardLeft = Math.max(region.left, Math.min(cardRect.left, region.right - cardWidth)); const placedPanelLeft = Math.max(region.left, Math.min(placedCardLeft, region.right - width)); return { cardLeft: placedCardLeft, cardTop: groupTop, cardHeight: placedCardHeight, panelLeft: placedPanelLeft, panelTop: groupTop + placedCardHeight + gap, panelHeight: placedPanelHeight, score: Math.abs(placedCardLeft - cardRect.left) + Math.abs(groupTop - cardRect.top) + (cardHeight - placedCardHeight) * 2 + (height - placedPanelHeight) * 2, }; }).sort((first, second) => first.score - second.score); if (stackedRegions.length) { const placement = stackedRegions[0]; card.style.left = `${Math.round(placement.cardLeft)}px`; card.style.top = `${Math.round(placement.cardTop)}px`; if (placement.cardHeight < cardHeight) card.style.maxHeight = `${Math.floor(placement.cardHeight)}px`; panel.style.left = `${Math.round(placement.panelLeft)}px`; panel.style.top = `${Math.round(placement.panelTop)}px`; if (placement.panelHeight < height) panel.style.maxHeight = `${Math.floor(placement.panelHeight)}px`; positionFallbackUserCardNotificationMenu(card); return; } panel.style.left = `${Math.round(fallbackLeft)}px`; panel.style.top = `${Math.round(fallbackTop)}px`; } function positionUserCardFollowPreview(preview, panel, card) { if (!preview || !panel || panel.hidden || !card) return; const margin = 10; const gap = 8; const panelRect = panel.getBoundingClientRect(); const cardRect = card.getBoundingClientRect(); const width = preview.offsetWidth || 320; const height = Math.min(preview.offsetHeight || 220, window.innerHeight - margin * 2); const panelIsRightOfCard = panelRect.left >= cardRect.right; const preferredLeft = panelIsRightOfCard ? panelRect.right + gap : panelRect.left - width - gap; const alternateLeft = panelIsRightOfCard ? panelRect.left - width - gap : panelRect.right + gap; const preferredFits = preferredLeft >= margin && preferredLeft + width <= window.innerWidth - margin; const left = preferredFits ? preferredLeft : alternateLeft; preview.style.left = `${Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin)))}px`; preview.style.top = `${Math.round(Math.max(margin, Math.min(panelRect.top, window.innerHeight - height - margin)))}px`; } function userStatValue(user, keys) { const sources = [user && user.user_summary, user, user && user.summary, user && user.user_stat, user && user.stats].filter(Boolean); for (const key of keys) { for (const source of sources) { const value = source[key]; if (value != null && value !== '') return value; } } return null; } function userCardStat(value, label, href) { const num = Number(value); const inner = `<strong>${Number.isFinite(num) ? esc(num) : '-'}</strong><span>${esc(label)}</span>`; if (!href) return `<div class="ldp-user-card-stat">${inner}</div>`; return `<a class="ldp-user-card-stat" href="${escAttr(href)}" aria-label="查看${escAttr(label)}">${inner}</a>`; } function isSafeUserCardUrl(value) { const url = String(value || '').trim(); return /^(https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('data:image/'); } function sanitizeUserCardBioHtml(value) { const template = document.createElement('template'); template.innerHTML = String(value || ''); const allowedTags = new Set(['A', 'B', 'BR', 'EM', 'I', 'IMG', 'P', 'SPAN', 'STRONG']); const allowedAttrs = { A: new Set(['href']), IMG: new Set(['src', 'alt', 'class', 'width', 'height']), SPAN: new Set(['class']), }; const cleanNode = (node) => { if (node.nodeType === Node.TEXT_NODE) return esc(node.textContent || ''); if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName; const children = Array.from(node.childNodes).map(cleanNode).join(''); if (!allowedTags.has(tag)) return children; const attrs = Array.from(node.attributes) .filter((attr) => { const name = attr.name.toLowerCase(); if (name.startsWith('on') || name === 'style') return false; if (!(allowedAttrs[tag] && allowedAttrs[tag].has(name))) return false; if ((name === 'href' || name === 'src') && !isSafeUserCardUrl(attr.value)) return false; return true; }) .map((attr) => ` ${attr.name.toLowerCase()}="${escAttr(attr.value)}"`).join(''); if (tag === 'A') return `<a${attrs} target="_blank" rel="noopener">${children}</a>`; if (tag === 'IMG') return `<img${attrs} loading="lazy" decoding="async">`; return `<${tag.toLowerCase()}${attrs}>${children}</${tag.toLowerCase()}>`; }; return Array.from(template.content.childNodes).map(cleanNode).join(''); } function userCardProgressHtml(phase) { if (phase === 'ready') return ''; const loadingStats = phase === 'stats'; const label = loadingStats ? '资料已获取,正在获取统计…' : '正在获取用户资料…'; return `<div class="ldp-user-card-progress${loadingStats ? ' is-stats' : ''}" role="status" aria-live="polite"> <span>${label}</span><span class="ldp-user-card-progress-track"><span class="ldp-user-card-progress-fill"></span></span> </div>`; } function userRegistrationDate(value) { const date = new Date(value || 0); if (!Number.isFinite(date.getTime())) return ''; return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`; } function userRecentDate(value) { const date = new Date(value || 0); if (!Number.isFinite(date.getTime())) return ''; const elapsed = Date.now() - date.getTime(); if (elapsed >= 0 && elapsed < 60 * 1000) return '刚刚'; if (elapsed >= 0 && elapsed < 60 * 60 * 1000) return `${Math.max(1, Math.floor(elapsed / 60000))} 分钟前`; if (elapsed >= 0 && elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`; if (date.getFullYear() === new Date().getFullYear()) return `${date.getMonth() + 1} 月 ${date.getDate()} 日`; return userRegistrationDate(value); } function userVisibleGroups(groups) { const visible = new Map(); (Array.isArray(groups) ? groups : []).forEach((group) => { const name = String(group && group.name || '').trim(); if (!name || /^trust_level_[0-9]+$/i.test(name)) return; const key = name.toLocaleLowerCase(); if (visible.has(key)) return; visible.set(key, { name, label: String(group.full_name || group.display_name || group.title || name).trim() || name, }); }); return Array.from(visible.values()); } function userCardGroupsHtml(user) { const groups = userVisibleGroups([ ...(Array.isArray(user && user.groups) ? user.groups : []), user && user.primary_group_name ? { name: user.primary_group_name } : null, ].filter(Boolean)); if (!groups.length) return ''; return `<div class="ldp-user-card-groups" aria-label="用户分组"> <span>用户分组</span><div>${groups.map((group) => `<a href="${BASE}/g/${encodeURIComponent(group.name)}" target="_blank" rel="noopener" data-ldp-tooltip-label="进入 ${escAttr(group.label)} 分组">${esc(group.label)}</a>`).join('')}</div> </div>`; } function userProfileFactsHtml(user, variant, options = {}) { const fact = (label, value, factOptions = {}) => { if (value == null || value === '') return ''; const classes = ['ldp-user-profile-fact']; if (factOptions.accent) classes.push('is-accent'); if (factOptions.wide) classes.push('is-wide'); const tooltip = factOptions.title ? ` data-ldp-tooltip-label="${escAttr(factOptions.title)}"` : ''; return `<span class="${classes.join(' ')}"${tooltip}><span class="ldp-user-profile-fact-label">${esc(label)}</span><span class="ldp-user-profile-fact-value">${esc(value)}</span></span>`; }; const numberValue = (keys) => { const value = userStatValue(user, keys); if (!options.emptyNumberAsZero && (value == null || value === '')) return ''; const numeric = Number(value); return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN') : ''; }; const trustLabels = ['新用户', '基本用户', '成员', '活跃用户', '领导者']; const trustLevel = userTrustLevel(user); const groups = userVisibleGroups([ ...(Array.isArray(user && user.groups) ? user.groups : []), options.includePrimaryGroup && user && user.primary_group_name ? { name: user.primary_group_name } : null, ].filter(Boolean)); const groupText = groups.map((group) => group.label).join(', '); const facts = [ fact('加入日期:', userRegistrationDate(user && user.created_at)), fact('最后一个帖子', userRecentDate(user && (user.last_posted_at || user.last_post_at))), fact('最后活动', userRecentDate(user && (user.last_seen_at || user.last_active_at))), fact('浏览量', numberValue(['profile_view_count', 'profile_views', 'views'])), fact('信任级别', Number.isFinite(trustLevel) ? (trustLabels[trustLevel] || `Lv${trustLevel}`) : ''), fact('电子邮件', user && user.email, { wide: true }), ...(variant === 'card' ? [] : [fact('群组', groupText, { accent: true, wide: true, title: options.groupTooltip ? groupText : '' })]), ...(variant === 'card' ? [] : [ fact('正在关注', numberValue(['total_following', 'following_count', 'followings_count'])), fact('关注者', numberValue(['total_followers', 'follower_count', 'followers_count'])), ]), fact('点数', numberValue(['gamification_score', 'points']), { accent: true }), ].filter(Boolean); return facts.length ? `<div class="ldp-user-profile-facts is-${variant}">${facts.join('')}</div>` : ''; } function userCardFollowStatsHtml(user, username, options = {}) { const interactive = options.interactive !== false; const configs = [ { type: 'following', label: '关注', total: userStatValue(user, ['total_following', 'following_count', 'followings_count']), visible: user && user.can_see_following !== false }, { type: 'followers', label: '被关注', total: userStatValue(user, ['total_followers', 'follower_count', 'followers_count']), visible: user && user.can_see_followers !== false }, ]; return `<div class="ldp-user-card-follow-stats" aria-label="用户关注关系"> ${configs.map((item) => { const total = Number(item.total); const count = Number.isFinite(total) ? total.toLocaleString('zh-CN') : '-'; if (!interactive) { return `<span class="ldp-user-card-follow-stat is-readonly" aria-label="${escAttr(`${item.label} ${count}`)}"> <strong>${esc(count)}</strong><span>${esc(item.label)}</span> </span>`; } const label = item.visible ? `查看${item.label}人员` : `${item.label}列表不可见`; return `<button class="ldp-user-card-follow-stat" type="button" data-user-card-follow-type="${item.type}" data-username="${escAttr(username)}" aria-controls="ldp-user-card-follow-panel" aria-expanded="false" aria-label="${escAttr(label)}"${item.visible ? '' : ' disabled'}> <strong>${esc(count)}</strong><span>${esc(item.label)}</span> </button>`; }).join('')} </div>`; } function userCardFollowItemHtml(user) { const username = String(user && user.username || ''); const name = String(user && user.name || username); const avatar = avatarWithFlairHtml( persistentAvatarHtml(user && user.avatar_template, 48, 'ldp-user-card-follow-avatar', name), user ); return `<a class="ldp-user-card-follow-item ldp-user-link" href="${userProfileUrl(username)}" target="_blank" rel="noopener" role="listitem" data-user-card="${escAttr(username)}"> ${avatar}<span><strong>${esc(name)}</strong><small>@${esc(username)}</small></span> </a>`; } function renderUserCardFollowList(panel) { if (panel?.hidden) return; const list = panel && panel.querySelector('.ldp-user-card-follow-list'); const summary = panel && panel.querySelector('.ldp-user-card-follow-summary'); const input = panel && panel.querySelector('input[type="search"]'); const pagination = panel && panel.querySelector('.ldp-user-card-follow-pagination'); if (!panel || !list || !summary || !input || !pagination) return; const users = Array.isArray(panel._ldpFollowUsers) ? panel._ldpFollowUsers : []; const query = normalizeSearchText(input.value); const matches = query ? users.filter((user) => user.searchText.includes(query)) : users; const pageCount = Math.max(1, Math.ceil(matches.length / USER_FOLLOW_LIST_PAGE_SIZE)); const requestedPage = Math.max(0, Number(panel.dataset.followPage) || 0); const page = Math.min(requestedPage, pageCount - 1); const offset = page * USER_FOLLOW_LIST_PAGE_SIZE; const visible = matches.slice(offset, offset + USER_FOLLOW_LIST_PAGE_SIZE); panel.dataset.followPage = String(page); releasePersistentAvatarImages(list); list.innerHTML = visible.length ? visible.map(userCardFollowItemHtml).join('') : `<div class="ldp-user-card-follow-empty">${query ? '没有匹配的人员' : '暂无人员'}</div>`; summary.textContent = query ? `找到 ${matches.length} 人` : `${matches.length} 人`; pagination.hidden = matches.length <= USER_FOLLOW_LIST_PAGE_SIZE; pagination.querySelector('span').textContent = `${page + 1} / ${pageCount}`; pagination.querySelector('[data-user-card-follow-page="previous"]').disabled = page === 0; pagination.querySelector('[data-user-card-follow-page="next"]').disabled = page >= pageCount - 1; list.scrollTop = 0; hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.userCard); } function connectNumber(value) { const match = String(value || '').replace(/,/g, '').match(/-?\d+/); return match ? Number(match[0]) : 0; } function connectCurrentTarget(value) { const parts = String(value || '').replace(/,/g, '').split('/'); if (parts.length >= 2) return { current: connectNumber(parts[0]), target: connectNumber(parts[1]) }; const numbers = String(value || '').replace(/,/g, '').match(/-?\d+/g) || []; return { current: Number(numbers[0] || 0), target: Number(numbers[1] || 0) }; } function parseConnectTrustHtml(html) { const doc = new globalThis.DOMParser().parseFromString(String(html || ''), 'text/html'); const card = Array.from(doc.querySelectorAll('.card')).find((candidate) => { const heading = candidate.querySelector('.card-title,h2'); return heading && /信任级别\s*\d+\s*的要求/.test(heading.textContent || ''); }); if (!card) throw new Error('Connect 未返回升级要求,请先登录 Connect'); const heading = String(card.querySelector('.card-title,h2')?.textContent || '').trim(); const targetLevel = Number((heading.match(/信任级别\s*(\d+)\s*的要求/) || [])[1]); const subtitle = String(card.querySelector('.card-subtitle')?.textContent || '').trim(); const username = (subtitle.match(/@([^\s·]+)/) || [])[1] || ''; const timePeriod = Number((subtitle.match(/过去\s*([\d,]+)\s*天/) || [])[1]?.replace(/,/g, '')) || 100; const rings = Array.from(card.querySelectorAll('.tl3-ring')).map((item) => ({ label: String(item.querySelector('.tl3-ring-label')?.textContent || '').trim(), current: connectNumber(item.querySelector('.tl3-ring-current')?.textContent), target: connectNumber(item.querySelector('.tl3-ring-target')?.textContent), met: item.querySelector('.tl3-ring-circle')?.classList.contains('met') || false, })).filter((item) => item.label); const requirementGroups = [ ['bars', '.tl3-bar-item', '.tl3-bar-label', '.tl3-bar-nums'], ['quotas', '.tl3-quota-card', '.tl3-quota-label', '.tl3-quota-nums'], ['vetoes', '.tl3-veto-item', '.tl3-veto-label', '.tl3-veto-value'], ]; const { bars, quotas, vetoes } = Object.fromEntries(requirementGroups.map(([ group, itemSelector, labelSelector, valueSelector, ]) => [group, Array.from(card.querySelectorAll(itemSelector)).map((item) => { const valueNode = item.querySelector(valueSelector); const values = group === 'vetoes' ? { current: connectNumber(valueNode?.textContent), target: 0 } : connectCurrentTarget(valueNode?.textContent); const met = group === 'bars' ? valueNode?.classList.contains('met') || item.querySelector('.tl3-bar-fill')?.classList.contains('met') || false : item.classList.contains('met') || (group === 'quotas' ? values.current <= values.target : values.current === 0); return { label: String(item.querySelector(labelSelector)?.textContent || '').trim(), ...values, met, ...(group === 'bars' ? {} : { reverse: true }), }; }).filter((item) => item.label)])); const statusNode = card.querySelector('.status-met,.status-unmet'); const badgeNode = card.querySelector('.badge'); const allRequirements = [...rings, ...bars, ...quotas, ...vetoes]; const met = statusNode ? statusNode.classList.contains('status-met') : badgeNode ? !/未达到|未达/.test(badgeNode.textContent || '') : allRequirements.every((item) => item.met); return { targetLevel: Number.isFinite(targetLevel) ? targetLevel : null, username, timePeriod, rings, bars, quotas, vetoes, met, }; } function requestConnectTrustHtml() { const request = globalThis.GM_xmlhttpRequest || globalThis.GM?.xmlHttpRequest; if (typeof request !== 'function') return Promise.reject(new Error('当前脚本未获得 Connect 跨域读取权限')); return new Promise((resolve, reject) => { request({ method: 'GET', url: 'https://connect.linux.do/', headers: { 'Accept': 'text/html' }, timeout: 20000, onload: (response) => { if (response.status >= 200 && response.status < 300) resolve(response.responseText || ''); else reject(new Error(`Connect 请求失败(${response.status || '未知状态'})`)); }, ontimeout: () => reject(new Error('Connect 请求超时')), onerror: () => reject(new Error('Connect 请求失败')), }); }); } function fetchConnectTrustData(force = false, persistedCache = null) { const freshFor = 5 * MINUTE; if (!CONNECT_TRUST_CACHE && persistedCache && persistedCache.data) { CONNECT_TRUST_CACHE = { data: persistedCache.data, cachedAt: Number(persistedCache.cachedAt || 0), }; } if (CONNECT_TRUST_REQUEST) return CONNECT_TRUST_REQUEST; if (!force && CONNECT_TRUST_CACHE && Date.now() - CONNECT_TRUST_CACHE.cachedAt < freshFor) { return Promise.resolve(CONNECT_TRUST_CACHE.data); } CONNECT_TRUST_REQUEST = requestConnectTrustHtml().then(parseConnectTrustHtml).then((data) => { CONNECT_TRUST_CACHE = { data, cachedAt: Date.now() }; return data; }).finally(() => { CONNECT_TRUST_REQUEST = null; }); return CONNECT_TRUST_REQUEST; } function settingsUserProfileHtml(user) { const username = String(user && user.username || ME_USERNAME || ''); const name = String(user && user.name || username); const trustLevel = userTrustLevel(user); const background = String(user && ( user.card_background_upload_url || user.profile_background_upload_url ) || '').trim(); const avatar = avatarWithFlairHtml( persistentAvatarHtml(user && user.avatar_template, 144, 'ldp-user-info-avatar-image', name), user ); const title = String(user && (user.title || user.flair_name) || '').trim(); const titleFlair = userFlairHtml(user); const bio = String(user && (user.bio_excerpt || user.bio_raw) || '').trim(); const website = String(user && (user.website_name || user.website) || '').trim(); const websiteUrl = String(user && (user.website || user.website_name) || '').trim(); const safeWebsite = websiteUrl && isSafeUserCardUrl(websiteUrl) ? (websiteUrl.startsWith('/') ? `${BASE}${websiteUrl}` : websiteUrl) : ''; return `<section class="ldp-user-info-profile" aria-label="当前用户资料"> <div class="ldp-user-info-cover">${background && isSafeUserCardUrl(background) ? `<img src="${escAttr(absoluteImageUrl(background))}" alt="" loading="lazy" decoding="async">` : ''}</div> <div class="ldp-user-info-profile-body"> <div class="ldp-user-info-avatar">${avatar || esc(name.slice(0, 1).toUpperCase())}</div> <div class="ldp-user-info-identity"> <div class="ldp-user-info-name-row"><strong class="ldp-user-info-name">${esc(name)}</strong>${Number.isFinite(trustLevel) ? `<span class="ldp-user-info-level">Lv${trustLevel}</span>` : ''}</div> <div class="ldp-user-info-username">@${esc(username)}</div> ${title ? `<div class="ldp-user-info-title">${titleFlair}${esc(title)}</div>` : ''} ${safeWebsite ? `<a class="ldp-user-info-site" href="${escAttr(safeWebsite)}" target="_blank" rel="noopener">${icon('externalLink')}<span>${esc(website || websiteUrl)}</span></a>` : ''} </div> ${bio ? `<div class="ldp-user-info-bio">${sanitizeUserCardBioHtml(bio)}</div>` : ''} ${userProfileFactsHtml(user, 'settings', { emptyNumberAsZero: true })} </div> </section>`; } function connectProgress(item) { if (item.reverse) { if (item.target === 0) return 100; return Math.max(0, Math.min(100, item.current / item.target * 100)); } return Math.max(0, Math.min(100, item.target > 0 ? item.current / item.target * 100 : item.met ? 100 : 0)); } function connectAchievementClass(item) { if (item.reverse || !(item.target > 0) || item.current < item.target) return ''; const ratio = item.current / item.target; if (ratio >= 10) return ' is-goal is-over is-over-high is-over-ultra is-over-epic'; if (ratio >= 5) return ' is-goal is-over is-over-high is-over-ultra'; if (ratio >= 2) return ' is-goal is-over is-over-high'; if (ratio > 1) return ' is-goal is-over'; return ' is-goal'; } function connectMetricBelowTarget(item) { return !item.reverse && Number(item.target) > 0 && Number(item.current) < Number(item.target); } const CONNECT_REQUIREMENT_HELP = [ [/访问天数|days?visited/, (period) => `访问天数:过去 ${period} 天内,访问站点并至少阅读过 1 个帖子的不同自然日数量。`], [/浏览话题|topics?viewed/, (period) => `浏览话题:过去 ${period} 天内浏览过的公开话题数量;目标按同期公开话题总量的一定比例计算,并受站点上限限制。`], [/浏览帖子|posts?read/, (period) => `浏览帖子:过去 ${period} 天内实际读过的公开帖子数量;目标按同期公开帖子总量的一定比例计算,并受站点上限限制。`], [/回复话题|topics?replied/, (period) => `回复话题:过去 ${period} 天内回复过的不同公开话题数量;同一话题回复多次仍只计 1 个。`], [/获赞天数|likes?received.*days/, (period) => `获赞天数:过去 ${period} 天内至少收到过 1 个赞的不同自然日数量;同一天收到多个赞仍只计 1 天。`], [/获赞用户|likes?received.*users/, (period) => `获赞用户:过去 ${period} 天内给你点过赞的不同用户数量;同一用户多次点赞仍只计 1 人。`], [/^获赞$|likes?received/, (period) => `获赞:过去 ${period} 天内,你在公开话题中的帖子收到的点赞总数。`], [/^点赞$|likes?given/, (period) => `点赞:过去 ${period} 天内,你在公开话题中送出的点赞总数。`], [/被举报帖子|flaggedposts/, (period) => `被举报帖子:过去 ${period} 天内,被其他用户举报且经管理确认的不同帖子数量。这是上限项,数值越少越好。`], [/举报用户|userswhoflagged|flaggedbyusers/, (period) => `举报用户:过去 ${period} 天内,对你的帖子发起且经管理确认举报的不同用户数量;不是你举报了多少人。这是上限项,数值越少越好。`], [/被禁言|silenced/, () => '被禁言:过去 6 个月内的禁言处罚记录,当前仍在禁言也会计入;此项必须为 0。'], [/被封禁|suspended/, () => '被封禁:过去 6 个月内的封禁处罚记录,当前仍在封禁也会计入;此项必须为 0。'], ]; function connectRequirementHelp(label, timePeriod = 100) { const normalized = String(label || '').replace(/\s+/g, '').toLowerCase(); const period = Number.isFinite(Number(timePeriod)) && Number(timePeriod) > 0 ? Number(timePeriod) : 100; const match = CONNECT_REQUIREMENT_HELP.find(([pattern]) => pattern.test(normalized)); return match ? match[1](period) : ''; } function settingsConnectHtml(data, error) { if (!data) return `<section class="ldp-connect-card"><div class="ldp-connect-head"><div class="ldp-connect-heading"><strong>Connect 升级进度</strong><small>升级要求来自 connect.linux.do</small></div></div><div class="ldp-connect-error"><span>${esc(error && error.message || '暂时无法读取 Connect 数据')}</span></div></section>`; const timePeriod = Number(data.timePeriod) > 0 ? Number(data.timePeriod) : 100; const valueLabel = (item) => `${Number(item.current).toLocaleString('zh-CN')} / ${Number(item.target).toLocaleString('zh-CN')}`; const style = (item, forceDanger = false) => `--ldp-connect-progress:${connectProgress(item).toFixed(1)}%;--ldp-connect-color:${forceDanger || connectMetricBelowTarget(item) || !item.met ? 'var(--danger,#c84545)' : 'var(--tertiary,#47855f)'}`; const metricHelp = (item) => { const help = connectRequirementHelp(item.label, timePeriod); return help ? ` data-ldp-tooltip-label="${escAttr(help)}"` : ''; }; const barGroup = (title, items) => items.length ? `<div class="ldp-connect-group"><div class="ldp-connect-group-title">${title}</div><div class="ldp-connect-bars">${items.map((item) => `<div class="ldp-connect-bar ldp-connect-metric${connectMetricBelowTarget(item) ? ' is-short' : ''}${connectAchievementClass(item)}" style="${style(item)}"${metricHelp(item)}><div class="ldp-connect-bar-copy"><span>${esc(item.label)}</span><strong>${esc(valueLabel(item))}</strong></div><div class="ldp-connect-bar-track"><span class="ldp-connect-bar-fill"></span></div></div>`).join('')}</div></div>` : ''; const quotaItems = [...data.quotas, ...data.vetoes]; const participationGroup = barGroup('参与互动', data.bars); const complianceGroup = quotaItems.length ? `<div class="ldp-connect-group"><div class="ldp-connect-group-title">合规记录</div><div class="ldp-connect-quotas">${quotaItems.map((item) => `<div class="ldp-connect-quota ldp-connect-metric" style="${style(item, Number(item.current) > 0)}"${metricHelp(item)}><div class="ldp-connect-quota-copy"><span>${esc(item.label)}</span><strong>${esc(valueLabel(item))}</strong></div><div class="ldp-connect-bar-track"><span class="ldp-connect-bar-fill"></span></div></div>`).join('')}</div></div>` : ''; return `<section class="ldp-connect-card" aria-label="Connect 升级进度"> <div class="ldp-connect-head"><div class="ldp-connect-heading"><strong>信任级别 ${esc(data.targetLevel == null ? '' : data.targetLevel)} 的要求</strong><small>${data.username ? `@${esc(data.username)} · ` : ''}过去 ${esc(timePeriod)} 天的数据</small></div><span class="ldp-connect-status ldp-connect-metric${data.met ? '' : ' is-unmet'}" data-ldp-tooltip-label="所有项目需要同时达标。互动与活跃项须达到下限;合规项不得超过上限,并在存在记录时以红色提醒。">${data.met ? '已达到' : '未达到'}</span></div> ${data.rings.length ? `<div class="ldp-connect-rings">${data.rings.map((item) => `<div class="ldp-connect-ring ldp-connect-metric" style="${style(item)}"${metricHelp(item)}><div class="ldp-connect-ring-visual"><span class="ldp-connect-ring-value">${esc(Number(item.current).toLocaleString('zh-CN'))}<small>/ ${esc(Number(item.target).toLocaleString('zh-CN'))}</small></span></div><span class="ldp-connect-ring-label">${esc(item.label)}</span></div>`).join('')}</div>` : ''} ${participationGroup || complianceGroup ? `<div class="ldp-connect-detail-groups">${participationGroup}${complianceGroup}</div>` : ''} ${error ? `<div class="ldp-connect-error"><span>当前显示缓存数据;${esc(error.message || '联网更新失败')}</span></div>` : ''} </section>`; } function renderSettingsUserInfo(container, user, connectData, connectError) { if (!container) return; const selectedView = ['connect', 'profile'].includes(container.dataset.userInfoView) ? container.dataset.userInfoView : 'connect'; container.dataset.userInfoView = selectedView; container.innerHTML = `<div class="ldp-user-info-tabs" role="tablist" aria-label="用户信息分类"> <button class="ldp-user-info-tab${selectedView === 'connect' ? ' active' : ''}" type="button" role="tab" data-user-info-view="connect" aria-selected="${selectedView === 'connect'}">${icon('activity')}<span>Connect</span></button> <button class="ldp-user-info-tab${selectedView === 'profile' ? ' active' : ''}" type="button" role="tab" data-user-info-view="profile" aria-selected="${selectedView === 'profile'}">${icon('userRound')}<span>基本信息</span></button> </div> <div class="ldp-user-info-view" role="tabpanel" data-user-info-panel="connect"${selectedView === 'connect' ? '' : ' hidden'}>${settingsConnectHtml(connectData, connectError)}</div> <div class="ldp-user-info-view" role="tabpanel" data-user-info-panel="profile"${selectedView === 'profile' ? '' : ' hidden'}>${settingsUserProfileHtml(user)}</div>`; hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.userCard); syncReaderSvgSymbols(container); } function userCardActionsHtml(user, username) { const privateMessageUnavailable = user && ( user.can_send_private_message_to_user === false || user.can_send_private_messages === false ); const followed = !!(user && user.is_followed); const followUnavailable = !!(user && user.can_follow === false && !followed); const endorsementUnavailable = !!(user && user.category_expert_endorsements === null); const notificationLevel = userNotificationLevel(user); const notificationUnavailable = !!(user && user.can_mute_user === false && user.can_ignore_user === false && notificationLevel === 'normal'); const actions = [ { action: 'message', icon: 'mail', label: privateMessageUnavailable ? '私信(当前不可用)' : '私信', disabled: privateMessageUnavailable }, { action: 'notifications', icon: 'bell', label: notificationUnavailable ? '消息设置(当前不可用)' : `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[notificationLevel]}`, active: notificationLevel !== 'normal', disabled: notificationUnavailable, expanded: true }, { action: 'endorse', icon: 'award', label: endorsementUnavailable ? '认可(当前不可用)' : '认可', disabled: endorsementUnavailable }, { action: 'follow', icon: 'userPlus', label: followUnavailable ? '关注(当前不可用)' : (followed ? '取消关注' : '关注'), active: followed, disabled: followUnavailable }, ]; const notificationOptions = [ { level: 'normal', icon: 'bell', label: '常规', description: '回复、引用或提到您时正常通知。' }, ...(user && user.can_mute_user === false ? [] : [ { level: 'mute', icon: 'bellOff', label: '免打扰', description: '不接收此用户的通知、私信和直接聊天。' }, ]), ...(user && user.can_ignore_user === false ? [] : [ { level: 'ignore', icon: 'eyeOff', label: '忽略', description: '隐藏此用户的内容,并停止相关通知。' }, ]), ]; return `<div class="ldp-user-card-actions-wrap"> <div class="ldp-user-card-actions" role="toolbar" aria-label="用户操作"> ${actions.map((item) => `<button type="button" class="ldp-user-card-action${item.active ? ' is-active' : ''}" data-user-card-action="${item.action}" data-username="${escAttr(username)}" data-tooltip="${escAttr(item.label)}" aria-label="${escAttr(item.label)}"${item.expanded ? ' aria-expanded="false"' : ''}${item.disabled ? ' disabled' : ''}> ${icon(item.icon)} </button>`).join('')} </div> <div class="ldp-user-card-notification-menu" role="menu" aria-label="消息设置" hidden> ${notificationOptions.map((item) => `<button type="button" role="menuitemradio" class="ldp-user-card-notification-option${item.level === notificationLevel ? ' is-active' : ''}" data-user-notification-level="${item.level}" aria-checked="${item.level === notificationLevel}"> ${icon(item.icon)}<span class="ldp-user-card-notification-option-copy"> <strong>${item.label}</strong><small>${item.description}</small> </span> </button>`).join('')} </div> </div>`; } function userCardHomeHtml(username) { return `<a class="ldp-user-card-home" href="${userProfileUrl(username)}" target="_blank" rel="noopener" data-tooltip="进入用户空间" aria-label="进入用户空间">${icon('externalLink')}</a>`; } function userCardBackgroundHtml(user) { const backgroundUrl = String(user && ( user.card_background_upload_url || user.cardBackgroundUploadUrl || user.profile_background_upload_url || user.profileBackgroundUploadUrl ) || '').trim(); if (!backgroundUrl || !isSafeUserCardUrl(backgroundUrl)) return ''; const source = absoluteImageUrl(backgroundUrl); return `<button class="ldp-user-card-background" type="button" data-user-card-background data-background-url="${escAttr(source)}" aria-label="预览用户背景图"> <img src="${escAttr(source)}" alt="" loading="lazy" decoding="async"> </button>`; } function userCardBadgeHash(badge) { const identity = `${badge && badge.id || ''}|${badge && badge.name || ''}|${badge && badge.icon || ''}`; let hash = 2166136261; for (let index = 0; index < identity.length; index++) { hash ^= identity.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } const USER_CARD_BADGE_EXACT_KINDS = Object.freeze({ '种子用户': 'seed', '龙行龘龘': 'dragon', '大预言家': 'crystal', '圆圆满满': 'moon', '浴火重生': 'fire', '海纳百川': 'waves', '一元复始': 'clock', '蛇来运转': 'snake', '破界者': 'hammer', '不二之选': 'star', '骐骥驰骋': 'horse', '幸运佬': 'clover', '金笔杆': 'pencil', '银笔杆': 'pencil', '铜笔杆': 'pencil', '文化宣导员': 'megaphone', '元气满满': 'gift', '基本用户': 'user', '成员': 'user', '活跃用户': 'fire', '领导者': 'crown', '阅读准则': 'book', '已认证': 'certificate', '已授权': 'certificate', '当月最佳新用户': 'star', '爱好者': 'calendar', '百尺竿头': 'calendar', '全年不落': 'calendar', '周年纪念日': 'calendar', '推广者': 'userplus', '活动家': 'userplus', '拥护者': 'userplus', '指导顾问': 'check', '无所不知': 'check', '解决方案机构': 'check', }); const USER_CARD_BADGE_KIND_RULES = Object.freeze([ ['mail', /mail|envelope|电子邮件|邮箱/], ['flag', /\bflag\b|report|举报/], ['at', /at-sign|mention|提及/], ['quote', /quote|引用/], ['box', /onebox|cube|box/], ['certificate', /certificate|认证|授权/], ['code', /code|github|commit|contributor|开源|贡献/], ['seed', /seed|sprout|幼苗|种子/], ['hammer', /hammer|gavel|破界/], ['calendar', /calendar|streak|连续|全年|纪念日/], ['userplus', /user-plus|invite|邀请|推广者|活动家|拥护者/], ['megaphone', /bullhorn|megaphone|announcement|公告|推广|广播/], ['heart', /heart|like|love|赞|爱心|喜爱|谢谢|回馈|善解人意/], ['eye', /\beye\b|view|reader|阅读|浏览|围观/], ['pencil', /pencil|edit|write|author|编辑|创作|作者|书写|笔杆|wiki/], ['document', /file|document|post|topic|article|文件|文档|帖子|主题|文章/], ['smile', /smile|laugh|emoji|表情|微笑|笑/], ['crown', /chess|crown|leader|king|领袖|领导|王者/], ['link', /link|share|链接|分享/], ['chat', /comment|chat|reply|conversation|回复|讨论|聊天|对话/], ['check', /check|solution|accepted|认可|解决|采纳|完成|顾问|无所不知/], ['star', /star|award|medal|荣誉|勋章|明星|精选|尊敬|敬仰|最佳/], ['shield', /shield|moderator|admin|管理|守护|安全/], ['clock', /clock|time|anniversary|year|周年|时间|资历/], ['fire', /fire|hot|active|热门|活跃|热心/], ['book', /book|learn|guide|tutorial|知识|教程|学习|指南/], ['gift', /gift|赠送|礼物/], ['user', /user|person|profile|member|用户|新人|成员|欢迎/], ]); function userCardBadgeGlyphKind(badge) { const name = String(badge && badge.name || '').trim(); if (USER_CARD_BADGE_EXACT_KINDS[name]) return USER_CARD_BADGE_EXACT_KINDS[name]; const source = `${badge && badge.icon || ''} ${name}`.toLowerCase(); return (USER_CARD_BADGE_KIND_RULES.find(([, pattern]) => pattern.test(source)) || ['sigil'])[0]; } const USER_CARD_BADGE_GLYPHS = Object.freeze({ mail: '<path d="M2 5h20v14H2V5zm3 2 7 5 7-5H5zm15 2.3-8 5.5-8-5.5V17h16V9.3z" fill-rule="evenodd"/>', flag: '<path d="M4 2h2v20H4V2zm3 2h13l-3 5 3 5H7V4z"/>', at: '<path d="M12 2a10 10 0 1 0 5.8 18.2l-1.3-1.7A7.8 7.8 0 1 1 19.8 12v1.2c0 1.2-.5 1.8-1.4 1.8-.8 0-1.3-.5-1.3-1.5V8h-2v1A5 5 0 1 0 16 16c.7.8 1.6 1.2 2.7 1.2 2.1 0 3.3-1.5 3.3-4V12c0-5.5-4.5-10-10-10zm0 12.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z" fill-rule="evenodd"/>', quote: '<path d="M3 5h8v8H7c0 3 1.3 4.8 4 5.5V21c-5.3-.8-8-4.2-8-10V5zm10 0h8v8h-4c0 3 1.3 4.8 4 5.5V21c-5.3-.8-8-4.2-8-10V5z"/>', box: '<path d="m12 2 9 5v10l-9 5-9-5V7l9-5zm0 2.8L6.1 8 12 11.2 17.9 8 12 4.8zM5 9.7v6.1l6 3.3V13L5 9.7zm8 9.4 6-3.3V9.7L13 13v6.1z" fill-rule="evenodd"/>', certificate: '<path d="M12 2a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm-3 7.2 2 2 4-4 1.5 1.5-5.5 5.5-3.5-3.5L9 9.2zM8 16l-2 6 6-2 6 2-2-6a9 9 0 0 1-8 0z" fill-rule="evenodd"/>', code: '<path d="m8.5 6-6 6 6 6 1.7-1.7L5.9 12l4.3-4.3L8.5 6zm7 0-1.7 1.7 4.3 4.3-4.3 4.3 1.7 1.7 6-6-6-6zM13 3 9 21h2l4-18h-2z"/>', seed: '<path d="M12 22v-8c-5-.3-8-3.2-8-8 4.8 0 7.2 1.6 8 4.7C12.8 7.6 15.2 6 20 6c0 4.8-3 7.7-8 8v8h-2z"/>', hammer: '<path d="m4 3 7 7-3 3-7-7 3-3zm8 5 3-3 4 4-3 3 6 6-4 4-6-6-3 3-4-4 7-7z"/>', calendar: '<path d="M3 4h3V2h2v2h8V2h2v2h3v18H3V4zm2 6v10h14V10H5zm0-4v2h14V6H5zm3 7h3v3H8v-3zm5 0h3v3h-3v-3z" fill-rule="evenodd"/>', userplus: '<path d="M9 2a5 5 0 1 1 0 10A5 5 0 0 1 9 2zM1 22c0-5 2.8-8 8-8 3.4 0 5.8 1.3 7 3.6V15h2v3h3v2h-3v3h-2v-3.4c-.7-.2-1.5-.3-2.4-.3-1.5 0-2.7.9-3.1 2.7H1z"/>', megaphone: '<path d="M3 10v4h3l3 3V7l-3 3H3zm7-3 9-3v16l-9-3V7zm-5 8h2l1.5 5H6.2L5 15z"/>', heart: '<path d="M12 20.5 4.2 13C-.5 8.2 6.1 2.1 12 7.2 17.9 2.1 24.5 8.2 19.8 13L12 20.5z"/>', eye: '<path d="M1.5 12s3.7-6 10.5-6 10.5 6 10.5 6-3.7 6-10.5 6S1.5 12 1.5 12zm10.5 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z" fill-rule="evenodd"/>', pencil: '<path d="m4 16.5-.8 4.3 4.3-.8L19.8 7.7l-3.5-3.5L4 16.5zm13.4-13.4 1.4-1.4a1.6 1.6 0 0 1 2.2 0l1.3 1.3a1.6 1.6 0 0 1 0 2.2l-1.4 1.4-3.5-3.5z"/>', document: '<path d="M5 2h9l5 5v15H5V2zm9 1.8V8h4.2L14 3.8zM8 12h8v-1.5H8V12zm0 4h8v-1.5H8V16zm0 4h6v-1.5H8V20z" fill-rule="evenodd"/>', smile: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-4 7.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm8 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-8.2 5h8.4c-.7 2.3-2.1 3.5-4.2 3.5s-3.5-1.2-4.2-3.5z" fill-rule="evenodd"/>', crown: '<path d="m3 7 4.5 3L12 4l4.5 6L21 7l-2 12H5L3 7zm3.4 10h11.2l.5-3H5.9l.5 3z" fill-rule="evenodd"/>', link: '<path d="M9.5 15.9 7.4 18a3 3 0 0 1-4.2-4.2l4-4a3 3 0 0 1 4.2 0l1 1-1.6 1.6-1-1a.8.8 0 0 0-1.1 0l-4 4a.8.8 0 0 0 1.1 1.1l2.1-2.1 1.6 1.5zm5-7.8L16.6 6a3 3 0 0 1 4.2 4.2l-4 4a3 3 0 0 1-4.2 0l-1-1 1.6-1.6 1 1a.8.8 0 0 0 1.1 0l4-4a.8.8 0 0 0-1.1-1.1l-2.1 2.1-1.6-1.5zM8.8 13.6l4.8-4.8 1.6 1.6-4.8 4.8-1.6-1.6z"/>', chat: '<path d="M3 4h18v13H9l-5.5 4v-4H3V4zm4 5h10V7.5H7V9zm0 4h7v-1.5H7V13z" fill-rule="evenodd"/>', check: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-2 14.5-4-4 1.8-1.8 2.2 2.2 6.2-6.2L18 8.5l-8 8z" fill-rule="evenodd"/>', star: '<path d="m12 2.5 3 6.1 6.7 1-4.9 4.7 1.2 6.7-6-3.2-6 3.2 1.2-6.7-4.9-4.7 6.7-1 3-6.1z"/>', shield: '<path d="M12 2 21 5v6c0 5.7-3.7 9.4-9 11-5.3-1.6-9-5.3-9-11V5l9-3zm0 3L6 7v4c0 3.9 2.2 6.5 6 8 3.8-1.5 6-4.1 6-8V7l-6-2z" fill-rule="evenodd"/>', clock: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 5h-2v6l5 3 1-1.7-4-2.3V7z" fill-rule="evenodd"/>', fire: '<path d="M13.5 2c1 4-1.5 5.2-1.5 8 0 1.1.7 2 1.7 2 1.8 0 2.5-2.1 2-4.2 3 2.4 4.3 5 3.7 8A7.5 7.5 0 0 1 5 15c-.3-3.4 1.5-6.5 5.1-9.4-.2 3.6 1.3 4.2 2 2.4.6-1.8.1-3.8 1.4-6z"/>', book: '<path d="M3 4h7c1.2 0 2.2.4 3 1.2A4.1 4.1 0 0 1 16 4h5v15h-5c-1.2 0-2.2.5-3 1.5A3.8 3.8 0 0 0 10 19H3V4zm9 3.5A2.8 2.8 0 0 0 10 6H5v11h5c.7 0 1.4.2 2 .5v-10zm2 10c.6-.3 1.3-.5 2-.5h3V6h-3c-.8 0-1.5.5-2 1.5v10z" fill-rule="evenodd"/>', gift: '<path d="M2 9h20v4h-1v9H3v-9H2V9zm3 4v7h6v-7H5zm8 0v7h6v-7h-6zM7.5 8C4 8 4 3 7 3c2 0 3.5 2.4 5 5H7.5zm9 0H12c1.5-2.6 3-5 5-5 3 0 3 5-.5 5z" fill-rule="evenodd"/>', user: '<path d="M12 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10zM3 22c0-5 3.2-8 9-8s9 3 9 8H3z"/>', dragon: '<path d="M3 17c2-6 5-10 10-12l-1 4c4-2 7-1 9 2l-4 1 3 3-5 1c-2 4-6 6-12 4l4-2-4-1zm8-4 2 2 2-3-4 1z" fill-rule="evenodd"/>', crystal: '<path d="M12 2a8 8 0 0 1 5 14.2L20 22H4l3-5.8A8 8 0 0 1 12 2zm0 3a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm-4 14-1 2h10l-1-2H8z" fill-rule="evenodd"/>', moon: '<path d="M16.5 2.5A10 10 0 1 0 21.5 17 8 8 0 0 1 16.5 2.5z"/>', waves: '<path d="M2 7c3 0 3 2 6 2s3-2 6-2 3 2 6 2h2v3h-2c-3 0-3-2-6-2s-3 2-6 2-3-2-6-2V7zm0 7c3 0 3 2 6 2s3-2 6-2 3 2 6 2h2v3h-2c-3 0-3-2-6-2s-3 2-6 2-3-2-6-2v-3z"/>', snake: '<path d="M18 3c3 0 4 2 4 4 0 3-2 4-5 4h-6c-1.5 0-2 .7-2 1.5S9.5 14 11 14h3c4 0 6 1.8 6 4.5S18 23 14 23H5v-3h9c1.8 0 3-.5 3-1.5S15.8 17 14 17h-3c-3.5 0-5-1.8-5-4.5S7.5 8 11 8h6c1.3 0 2-.4 2-1s-.7-1-2-1h-2V3h3zM4 17l-3-3 3-3v6z"/>', horse: '<path d="M6 22v-7l3-4-1-5 5-4 1 4 5 2-1 6-4 2v6h-3v-7l3-2 1-3-4-1-4 7v6H6z"/>', clover: '<path d="M12 11C8-1 0 3 5 10-2 8-1 18 8 15c-4 7 6 10 7 2 7 5 11-5 3-7 6-6-3-12-6 1zm0 3 2 8h-4l2-8z" fill-rule="evenodd"/>', }); function userCardBadgeGlyphHtml(kind, badge) { if (USER_CARD_BADGE_GLYPHS[kind]) return USER_CARD_BADGE_GLYPHS[kind]; const hash = userCardBadgeHash(badge); const points = Array.from({ length: 8 }, (_, index) => { const angle = -Math.PI / 2 + index * Math.PI / 4; const radius = 7 + ((hash >>> (index * 4)) & 3); return `${12 + Math.cos(angle) * radius},${12 + Math.sin(angle) * radius}`; }).join(' '); const core = 2.5 + ((hash >>> 29) & 3) * .65; return `<polygon points="${points}"/><circle cx="12" cy="12" r="${core}" fill="var(--secondary,#fff)"/><circle cx="12" cy="12" r="${Math.max(1, core - 1.5)}"/>`; } function userCardBadgeIconHtml(badge) { return `<svg class="ldp-user-card-badge-icon" viewBox="0 0 24 24" aria-hidden="true"> <g transform="translate(3 1) scale(.75)">${userCardBadgeGlyphHtml(userCardBadgeGlyphKind(badge), badge)}</g> </svg>`; } function userCardBadgesHtml(user) { const badges = uniqueUserBadges(user && user.user_badges); if (!badges.length) return ''; const featuredIds = new Set((Array.isArray(user && user.featured_user_badge_ids) ? user.featured_user_badge_ids : []).map(Number)); const badgeSortNumber = (badge, field, minimum = 0) => { const value = Number(badge && badge[field]); return Number.isFinite(value) && value >= minimum ? value : Number.MAX_SAFE_INTEGER; }; const ordered = badges.slice().sort((a, b) => badgeSortNumber(a, 'badge_type_id', 1) - badgeSortNumber(b, 'badge_type_id', 1) || badgeSortNumber(a, 'grant_count') - badgeSortNumber(b, 'grant_count') || Number(featuredIds.has(Number(b && b.id))) - Number(featuredIds.has(Number(a && a.id))) || String(b && b.granted_at || '').localeCompare(String(a && a.granted_at || '')) ); return `<div class="ldp-user-card-badges" aria-label="用户徽章,共 ${ordered.length} 枚"> <span class="ldp-user-card-badges-label">徽章(${ordered.length})</span> <div class="ldp-user-card-badge-strip"> <button class="ldp-user-card-badge-scroll is-prev" type="button" data-user-card-badge-scroll="-1" aria-label="向左查看更多徽章" hidden>${icon('chevronRight')}</button> <div class="ldp-user-card-badge-list" tabindex="0" aria-label="全部 ${ordered.length} 枚徽章,可左右滚动查看"> ${ordered.map((badge) => { const name = String(badge.name || '').trim(); const tier = Number(badge && badge.badge_type_id); return `<button class="ldp-user-card-badge" type="button" data-badge-tier="${Number.isFinite(tier) ? tier : ''}" data-ldp-tooltip-label="${escAttr(name)}" aria-label="${escAttr(name)}">${userCardBadgeIconHtml(badge)}</button>`; }).join('')} </div> <button class="ldp-user-card-badge-scroll is-next" type="button" data-user-card-badge-scroll="1" aria-label="向右查看更多徽章" hidden>${icon('chevronRight')}</button> </div> </div>`; } function syncUserCardBadgeScrollControls(root) { if (!root) return; root.querySelectorAll('.ldp-user-card-badge-strip').forEach((strip) => { const list = strip.querySelector('.ldp-user-card-badge-list'); const previous = strip.querySelector('[data-user-card-badge-scroll="-1"]'); const next = strip.querySelector('[data-user-card-badge-scroll="1"]'); if (!list || !previous || !next) return; const maxScrollLeft = Math.max(0, list.scrollWidth - list.clientWidth); const overflow = maxScrollLeft > 1; strip.classList.toggle('is-scrollable', overflow); previous.hidden = !overflow; next.hidden = !overflow; previous.disabled = !overflow || list.scrollLeft <= 1; next.disabled = !overflow || list.scrollLeft >= maxScrollLeft - 1; }); } function userCardAvatarImageHtml(template, name, previewSource = '') { const source = avatarUrl(template, 96); const hasCachedSource = AVATAR_OBJECT_URL_CACHE.has(source); const preview = String(previewSource || '').trim(); if (!preview || hasCachedSource || source.startsWith('data:') || source.startsWith('blob:')) { return persistentAvatarHtml(template, 96, 'ldp-user-card-avatar', name); } return `<img class="ldp-user-card-avatar" src="${escAttr(preview)}" alt="" loading="eager" decoding="async" data-ldp-avatar-template="${escAttr(template)}" data-ldp-avatar-size="96" data-ldp-avatar-class="ldp-user-card-avatar">`; } function renderFallbackUserCard(user, username, phase = 'ready', avatarPreviewSource = '', options = {}) { user = user || {}; username = user.username || username || ''; const name = user.name || username; const avatar = avatarWithFlairHtml( userCardAvatarImageHtml(user.avatar_template, name, avatarPreviewSource), user ); const title = user.title || user.flair_name || ''; const bio = user.bio_excerpt || user.bio_raw || ''; const trustLevel = userTrustLevel(user); const avatarControl = !avatar ? '' : options.mediaInteractive === false ? `<span class="ldp-user-card-avatar-trigger">${avatar}</span>` : `<button class="ldp-user-card-avatar-trigger" type="button" data-user-card-avatar data-avatar-template="${escAttr(user.avatar_template)}" data-avatar-username="${escAttr(username)}" aria-label="查看 ${escAttr(name)} 的头像原图">${avatar}</button>`; return ` ${options.mediaInteractive === false ? '' : userCardBackgroundHtml(user)} ${userCardHomeHtml(username)} <div class="ldp-user-card-head"> ${avatarControl} <div class="ldp-user-card-identity"> <div class="ldp-user-card-name-row"> <div class="ldp-user-card-name">${esc(name)}</div> ${Number.isFinite(trustLevel) ? `<span class="ldp-user-card-level">Lv${esc(trustLevel)}</span>` : ''} </div> <div class="ldp-user-card-username">@${esc(username)}</div> ${title ? `<div class="ldp-user-card-title">${esc(title)}</div>` : ''} </div> </div> ${userProfileFactsHtml(user, 'card', { includePrimaryGroup: true, groupTooltip: true })} ${userCardGroupsHtml(user)} ${userCardFollowStatsHtml(user, username, { interactive: options.followStatsInteractive !== false })} ${bio ? `<div class="ldp-user-card-bio">${sanitizeUserCardBioHtml(bio)}</div>` : ''} ${userCardBadgesHtml(user)} <div class="ldp-user-card-stats"> ${userCardStat(userStatValue(user, ['post_count', 'posts_count']), '帖子', userActivityUrl(username, 'activity/replies'))} ${userCardStat(userStatValue(user, ['likes_received', 'likes_given']), '获赞', userActivityUrl(username, 'activity/likes-received'))} ${userCardStat(userStatValue(user, ['topic_count', 'topics_entered']), '主题', userActivityUrl(username, 'activity/topics'))} </div> ${userCardProgressHtml(phase)} ${options.actions === false ? '' : `${userCardActionsHtml(user, username)} <div class="ldp-user-card-action-status" role="status" aria-live="polite"></div>`}`; } function userCardSeedFromLink(link, username) { const head = link && link.closest('.ldp-post-head'); const post = head && head.closest('.ldp-post'); const postData = post && post._ldpPostData || {}; const avatar = head && head.querySelector('.ldp-avatar'); const author = head && head.querySelector('.ldp-author'); return { username, name: (author && author.textContent || '').trim() || username, _ldp_avatar_preview_url: avatar && (avatar.currentSrc || avatar.src) || '', avatar_template: avatar && ( avatar.dataset.ldpAvatarTemplate || avatar.dataset.ldpAvatarOriginalTemplate || avatar.currentSrc || avatar.src ) || '', flair_name: postData.flair_name, flair_url: postData.flair_url, flair_bg_color: postData.flair_bg_color, flair_color: postData.flair_color, }; } async function showFallbackUserCard(link, isCurrent, onOpen = null, options = {}) { const username = getUserCardUsername(link); if (!username || !isCurrent()) return; const card = options.card || getFallbackUserCard(); const positionCard = typeof options.positionCard === 'function' ? options.positionCard : (target) => positionFallbackUserCard(target, link); const seedUser = userCardSeedFromLink(link, username); const avatarPreviewSource = seedUser._ldp_avatar_preview_url || ''; const renderUser = (user, phase = 'ready') => { if (!isCurrent()) return; releasePersistentAvatarImages(card); card._ldpUserData = user || { username }; card.innerHTML = renderFallbackUserCard(user, username, phase, avatarPreviewSource, options); hydratePersistentAvatarImages(card, POST_REQUEST_PRIORITY.userCard); syncReaderSvgSymbols(card); syncUserCardBadgeScrollControls(card); requestAnimationFrame(() => card.isConnected && syncUserCardBadgeScrollControls(card)); card.classList.add('open'); positionCard(card, link); }; renderUser(seedUser, 'profile'); if (typeof onOpen === 'function') onOpen(card._ldpUserData); try { await fetchUserCard(username, renderUser); } catch (e) { if (!isCurrent()) return; card._ldpUserData = { username }; card.innerHTML = `${userCardHomeHtml(username)}<div class="ldp-user-card-bio">用户信息加载失败</div>${options.actions === false ? '' : `${userCardActionsHtml({}, username)}<div class="ldp-user-card-action-status" role="status" aria-live="polite"></div>`}`; positionCard(card, link); } } function composerElementAvailable(el) { if (!el || el.hidden || el.disabled || el.getAttribute('aria-hidden') === 'true') return false; if (el.classList.contains('hidden') || el.classList.contains('d-none')) return false; return !el.closest('[hidden],[aria-hidden="true"]'); } function nativeComposerVisible(el) { if (!composerElementAvailable(el) || el.classList.contains('closed')) return false; const style = getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight; } function visibleNativeFloatingSurface() { return Array.from(document.querySelectorAll('.fk-d-menu,.emoji-picker')).find(nativeComposerVisible) || null; } function createReaderWindowController(overlay, modal) { const capsule = overlay.querySelector('.ldp-reader-window-capsule'); const dragHandle = capsule.querySelector('.ldp-reader-drag-handle'); const lockButton = capsule.querySelector('.ldp-reader-lock-button'); const pinButton = capsule.querySelector('.ldp-reader-pin-button'); let state = null; let interaction = null; let frame = 0; let destroyed = false; const readerWindowPrefPrefix = overlay.dataset.readerListModeAllowed === 'true' ? 'listReaderWindow' : 'readerWindow'; const readerWindowPrefKey = (suffix) => `${readerWindowPrefPrefix}${suffix}`; const readerWindowPref = (suffix) => PREFS[readerWindowPrefKey(suffix)]; const setReaderWindowPref = (suffix, value) => setPref(readerWindowPrefKey(suffix), value); const setReaderWindowPrefs = (values) => setPrefs(Object.entries(values).reduce((next, [suffix, value]) => { next[readerWindowPrefKey(suffix)] = value; return next; }, {})); const isLocked = () => !!readerWindowPref('Locked'); const isPinned = () => !!readerWindowPref('Pinned'); const viewportBounds = () => ({ maxWidth: Math.max(1, window.innerWidth - READER_WINDOW_MARGIN * 2), maxHeight: Math.max(1, window.innerHeight - READER_WINDOW_MARGIN * 2), }); const clampSize = (width, height) => { const bounds = viewportBounds(); return { width: Math.min(bounds.maxWidth, Math.max(Math.min(READER_WINDOW_MIN_WIDTH, bounds.maxWidth), Math.round(width))), height: Math.min(bounds.maxHeight, Math.max(Math.min(READER_WINDOW_MIN_HEIGHT, bounds.maxHeight), Math.round(height))), }; }; const preferredSize = () => clampSize( readerWindowPref('Width') || Math.min(READER_WINDOW_DEFAULT_WIDTH, window.innerWidth * READER_WINDOW_DEFAULT_VIEWPORT_WIDTH), readerWindowPref('Height') || window.innerHeight * READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT ); const centeredState = () => { const size = preferredSize(); return clampState({ ...size, left: readerWindowPref('X') || Math.round((window.innerWidth - size.width) / 2), top: readerWindowPref('Y') || Math.round((window.innerHeight - size.height) / 2), }); }; const clampState = (next) => { const size = clampSize(next.width, next.height); return { ...size, left: Math.min(window.innerWidth - READER_WINDOW_MARGIN - size.width, Math.max(READER_WINDOW_MARGIN, Math.round(next.left))), top: Math.min(window.innerHeight - READER_WINDOW_MARGIN - size.height, Math.max(READER_WINDOW_MARGIN, Math.round(next.top))), }; }; const managed = () => overlay.dataset.readerWorkspaceMode === 'floating' && (overlay.dataset.readerListModeAllowed === 'true' || !PREFS.fullPage) && window.innerWidth > READER_WINDOW_COMPACT_WIDTH; const updateCapsulePlacement = (next) => { const docked = managed() && next && next.top < READER_WINDOW_HANDLE_DOCK_THRESHOLD; overlay.classList.toggle('ldp-window-handle-docked', !!docked); if (!next) return; overlay.style.setProperty('--ldp-reader-window-center-x', `${next.left + next.width / 2}px`); overlay.style.setProperty('--ldp-reader-window-top', `${next.top}px`); }; const syncLockButton = () => { const locked = isLocked(); const label = locked ? '解锁浮窗' : '锁定浮窗'; lockButton.classList.toggle('active', locked); lockButton.setAttribute('aria-pressed', String(locked)); lockButton.setAttribute('aria-label', label); if (lockButton.dataset.locked !== String(locked)) { lockButton.dataset.locked = String(locked); lockButton.innerHTML = icon(locked ? 'lock' : 'unlock'); } }; const animateLockButton = () => { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; lockButton.classList.remove('ldp-lock-state-changing'); void lockButton.offsetWidth; lockButton.classList.add('ldp-lock-state-changing'); }; const apply = () => { if (destroyed) return; const displayState = managed() ? clampState(state || centeredState()) : null; overlay.classList.toggle('ldp-window-locked', isLocked()); overlay.classList.toggle('ldp-window-pinned', isPinned()); overlay.classList.toggle('ldp-window-managed', managed()); syncLockButton(); pinButton.classList.toggle('active', isPinned()); pinButton.setAttribute('aria-pressed', String(isPinned())); pinButton.setAttribute('aria-label', isPinned() ? '取消置顶浮窗' : '置顶浮窗'); if (!managed()) { updateCapsulePlacement(null); modal.style.removeProperty('left'); modal.style.removeProperty('top'); modal.style.removeProperty('width'); modal.style.removeProperty('height'); return; } if (!state) state = { ...displayState }; modal.style.left = `${displayState.left}px`; modal.style.top = `${displayState.top}px`; modal.style.width = `${displayState.width}px`; modal.style.height = `${displayState.height}px`; updateCapsulePlacement(displayState); }; const stopInteraction = () => { if (!interaction) return; const { target, pointerId } = interaction; interaction = null; if (frame) cancelAnimationFrame(frame); frame = 0; overlay.classList.remove('ldp-window-interacting'); modal.style.removeProperty('transform'); capsule.style.removeProperty('transform'); try { if (target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId); } catch (e) {} }; const renderInteraction = () => { frame = 0; if (!interaction || !managed()) return; const dx = interaction.x - interaction.startX; const dy = interaction.y - interaction.startY; const start = interaction.start; if (interaction.type === 'drag') { interaction.preview = clampState({ ...start, left: start.left + dx, top: start.top + dy }); modal.style.transform = `translate3d(${interaction.preview.left - start.left}px,${interaction.preview.top - start.top}px,0)`; updateCapsulePlacement(interaction.preview); return; } const direction = interaction.type; let left = start.left; let top = start.top; let right = start.left + start.width; let bottom = start.top + start.height; if (direction.includes('w')) left = Math.min(right - READER_WINDOW_MIN_WIDTH, Math.max(READER_WINDOW_MARGIN, start.left + dx)); if (direction.includes('e')) right = Math.max(left + READER_WINDOW_MIN_WIDTH, Math.min(window.innerWidth - READER_WINDOW_MARGIN, right + dx)); if (direction.includes('n')) top = Math.min(bottom - READER_WINDOW_MIN_HEIGHT, Math.max(READER_WINDOW_MARGIN, start.top + dy)); if (direction.includes('s')) bottom = Math.max(top + READER_WINDOW_MIN_HEIGHT, Math.min(window.innerHeight - READER_WINDOW_MARGIN, bottom + dy)); state = clampState({ left, top, width: right - left, height: bottom - top }); apply(); }; const onPointerMove = (event) => { if (!interaction || event.pointerId !== interaction.pointerId) return; interaction.x = event.clientX; interaction.y = event.clientY; if (!frame) frame = requestAnimationFrame(renderInteraction); }; const persistGeometry = () => { setReaderWindowPrefs({ Width: state.width, Height: state.height, X: state.left, Y: state.top, }); }; const onPointerEnd = (event) => { if (!interaction || event.pointerId !== interaction.pointerId) return; if (frame) { cancelAnimationFrame(frame); frame = 0; renderInteraction(); } const resized = interaction.type !== 'drag'; if (!resized && interaction.preview) state = interaction.preview; stopInteraction(); if (state) { apply(); persistGeometry(); overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change')); } }; const beginInteraction = (event, type, target) => { if (event.button !== 0 || isLocked() || !managed()) return; event.preventDefault(); event.stopPropagation(); state = clampState(state || centeredState()); interaction = { type, target, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, start: { ...state }, }; overlay.classList.add('ldp-window-interacting'); target.setPointerCapture(event.pointerId); }; const windowEventBindings = []; const readerWindowHandle = (target) => { const handle = target instanceof Element && target.closest('.ldp-reader-drag-handle,[data-reader-resize]'); return handle && overlay.contains(handle) ? handle : null; }; const onHandlePointerDown = (event) => { const handle = readerWindowHandle(event.target); if (!handle) return; const type = handle === dragHandle ? 'drag' : handle.dataset.readerResize; if (!type) return; beginInteraction(event, type, handle); }; addTrackedEventListener(windowEventBindings, overlay, 'pointerdown', onHandlePointerDown); addTrackedEventListener(windowEventBindings, overlay, 'pointermove', onPointerMove); addTrackedEventListener(windowEventBindings, overlay, 'pointerup', onPointerEnd); addTrackedEventListener(windowEventBindings, overlay, 'pointercancel', onPointerEnd); const onLockAnimationEnd = () => lockButton.classList.remove('ldp-lock-state-changing'); const onLockClick = (event) => { event.preventDefault(); event.stopPropagation(); stopInteraction(); setReaderWindowPref('Locked', !isLocked()); apply(); animateLockButton(); overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change')); }; addTrackedEventListener(windowEventBindings, lockButton, 'click', onLockClick); addTrackedEventListener(windowEventBindings, lockButton, 'animationend', onLockAnimationEnd); const onPinClick = (event) => { event.preventDefault(); event.stopPropagation(); setReaderWindowPref('Pinned', !isPinned()); apply(); overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change')); }; addTrackedEventListener(windowEventBindings, pinButton, 'click', onPinClick); const onResize = () => { stopInteraction(); apply(); }; addTrackedEventListener(windowEventBindings, window, 'resize', onResize); apply(); return { syncMode() { stopInteraction(); apply(); }, getState() { return { ...(state ? (managed() ? clampState(state) : state) : centeredState()) }; }, setGeometry(width, height, left, top) { if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return; const current = state || centeredState(); const size = clampSize(Number(width) || current.width, Number(height) || current.height); state = clampState({ ...size, left: Number.isFinite(Number(left)) ? Number(left) : current.left, top: Number.isFinite(Number(top)) ? Number(top) : current.top, }); persistGeometry(); apply(); overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change')); }, setLocked(locked) { if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return; stopInteraction(); setReaderWindowPref('Locked', !!locked); apply(); }, setPinned(pinned) { if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return; setReaderWindowPref('Pinned', !!pinned); apply(); }, reset() { if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return; stopInteraction(); setReaderWindowPrefs({ Width: 0, Height: 0, X: 0, Y: 0, Locked: false, Pinned: false, }); state = centeredState(); apply(); overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change')); }, isDefault() { return !readerWindowPref('Width') && !readerWindowPref('Height') && !readerWindowPref('X') && !readerWindowPref('Y') && !isLocked() && !isPinned(); }, isLocked, isPinned, destroy() { if (destroyed) return; stopInteraction(); destroyed = true; removeTrackedEventListeners(windowEventBindings); }, }; } function syncNativeTopicStatsComponent(card, reactionCountsByTopicId) { const posts = card && card.querySelector(':scope > td.posts'); const views = card && card.querySelector(':scope > td.views'); const activity = card && card.querySelector(':scope > td.activity'); if (!posts || !views || !activity) return; let component = posts.querySelector(':scope > .ldp-topic-stats-component'); const sourceMarkup = (cell) => [...cell.childNodes] .filter((node) => node !== component) .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || '')) .join(''); const isOldTopic = views.dataset.ldpNativeOldTopic === 'true'; const responseCount = nativeTopicOpReactionCount(card, reactionCountsByTopicId); const sourceSignature = `${sourceMarkup(posts)}\u0001${sourceMarkup(views)}\u0001${sourceMarkup(activity)}\u0001${isOldTopic ? 'old' : 'recent'}\u0001${responseCount == null ? '' : responseCount}`; if (component && component.dataset.ldpSourceSignature === sourceSignature && component.querySelectorAll(':scope > .ldp-topic-stats-row').length === 2) return; const cloneSourceNodes = (cell) => [...cell.childNodes] .filter((node) => node !== component) .map((node) => node.cloneNode(true)); const postNodes = cloneSourceNodes(posts); const viewNodes = cloneSourceNodes(views); const activityNodes = cloneSourceNodes(activity); if (!component) { component = document.createElement('div'); component.className = 'ldp-topic-stats-component'; posts.append(component); } const makeStat = (kind, label, nodes) => { const item = document.createElement('span'); item.className = `ldp-topic-stat ldp-topic-stat--${kind}`; const caption = document.createElement('span'); caption.className = 'ldp-topic-stat-label'; caption.textContent = label; const value = document.createElement('span'); value.className = 'ldp-topic-stat-value'; nodes.forEach((node) => value.append(node)); item.append(caption, value); return item; }; const makeRow = (...items) => { const row = document.createElement('span'); row.className = 'ldp-topic-stats-row'; row.append(...items); return row; }; component.replaceChildren( makeRow( makeStat('reply', '回复', postNodes), makeStat(isOldTopic ? 'old' : 'date', isOldTopic ? '旧帖' : '最近回复', viewNodes) ), makeRow( makeStat('activity', '活跃', activityNodes), makeStat('response', '回应', [document.createTextNode(responseCount == null ? '—' : String(responseCount))]) ) ); component.dataset.ldpSourceSignature = sourceSignature; } function syncNativeTopicActivityStat(card) { const activity = card && card.querySelector(':scope > td.activity'); const component = card && card.querySelector(':scope > td.posts > .ldp-topic-stats-component'); const source = activity && activity.querySelector('.relative-date'); const target = component && component.querySelector('.ldp-topic-stat--activity .relative-date'); if (!activity || !component || !source || !target) return false; if (target.textContent !== source.textContent) target.textContent = source.textContent; const signatureParts = String(component.dataset.ldpSourceSignature || '').split('\u0001'); if (signatureParts.length === 5) { signatureParts[2] = [...activity.childNodes] .map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || '')) .join(''); component.dataset.ldpSourceSignature = signatureParts.join('\u0001'); } return true; } function nativeTopicOpReactionCounts() { const counts = new Map(); const router = lookupDiscourse('service:router'); const routeName = discourseModelValue(router, 'currentRouteName'); const controllerNames = [ routeName && `controller:${routeName}`, routeName && `controller:${String(routeName).replace(/\./g, '/')}`, 'controller:discovery/list', 'controller:tag/show', 'controller:tags/intersection', ].filter(Boolean); const seenControllers = new Set(); for (const name of controllerNames) { const controller = lookupDiscourse(name); if (!controller || seenControllers.has(controller)) continue; seenControllers.add(controller); const model = discourseModelValue(controller, 'model'); const list = discourseModelValue(model, 'list') || discourseModelValue(controller, 'list') || model; const topicsValue = discourseModelValue(list, 'topics') || discourseModelValue(list, 'content'); const topics = Array.isArray(topicsValue) ? topicsValue : topicsValue && typeof topicsValue.toArray === 'function' ? topicsValue.toArray() : []; topics.forEach((topic) => { const topicId = String(discourseModelValue(topic, 'id') || ''); if (!topicId || counts.has(topicId)) return; const reactionsData = discourseModelValue(topic, 'op_reactions_data') || discourseModelValue(topic, 'opReactionsData'); const rawCount = reactionsData ? discourseModelValue(reactionsData, 'reaction_users_count') ?? discourseModelValue(reactionsData, 'reactionUsersCount') : discourseModelValue(topic, 'op_like_count') ?? discourseModelValue(topic, 'opLikeCount'); const count = Number(rawCount); counts.set(topicId, Number.isFinite(count) && count >= 0 ? Math.trunc(count) : null); }); } return counts; } function nativeTopicOpReactionCount(card, reactionCountsByTopicId) { const directId = card && (card.dataset.topicId || card.getAttribute('data-topic-id')); const topicLink = card && card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]'); const topicId = String(directId || extractTopicRouteFromUrl(topicLink && topicLink.href)?.topicId || ''); if (!topicId) return null; return reactionCountsByTopicId.has(topicId) ? reactionCountsByTopicId.get(topicId) : null; } function syncNativeTopicTitleTools(card, dndControl) { const line = dndControl && dndControl.closest('.link-top-line'); if (!card || !line || !card.contains(line)) return; const normalizeText = (node) => String(node && node.textContent || '').replace(/\s+/g, ' ').trim(); const expertLabel = [...line.querySelectorAll('*')].find((node) => normalizeText(node) === '专家回应'); if (!expertLabel) return; const directChild = (node) => { let current = node; while (current && current.parentElement !== line) current = current.parentElement; return current && current.parentElement === line ? current : null; }; const expertItem = directChild(expertLabel); const dndItem = directChild(dndControl); if (!expertItem || !dndItem) return; if (expertItem === dndItem && expertItem.classList.contains('ldp-native-topic-title-tools')) return; const children = [...line.children]; const start = children.indexOf(expertItem); const end = children.indexOf(dndItem); if (start < 0 || end < start) return; const tools = children.slice(start, end + 1); const group = document.createElement('span'); group.className = 'ldp-native-topic-title-tools'; expertItem.before(group); group.append(...tools); } function markNativeDoNotDisturbBadges(scope = document, reactionCountsByTopicId = null) { const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item'; const cards = []; if (scope && typeof scope.matches === 'function' && scope.matches(cardSelector)) cards.push(scope); if (scope && typeof scope.querySelectorAll === 'function') cards.push(...scope.querySelectorAll(cardSelector)); if (!cards.length) return; const reactionCounts = reactionCountsByTopicId || nativeTopicOpReactionCounts(); cards.forEach((card) => { const cells = [...card.querySelectorAll(':scope > td')]; const dateCell = card.querySelector(':scope > td.views') || cells[3] || null; cells.forEach((cell) => { const text = cell === dateCell ? String(cell.textContent || '').replace(/\s+/g, ' ').trim() : ''; const hasTopicDate = /\b(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text); const hasOldTopicDate = /\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text); if (hasTopicDate && cell.dataset.ldpNativeTopicDate !== 'true') cell.dataset.ldpNativeTopicDate = 'true'; else if (!hasTopicDate && 'ldpNativeTopicDate' in cell.dataset) delete cell.dataset.ldpNativeTopicDate; if (hasOldTopicDate && cell.dataset.ldpNativeOldTopic !== 'true') cell.dataset.ldpNativeOldTopic = 'true'; else if (!hasOldTopicDate && 'ldpNativeOldTopic' in cell.dataset) delete cell.dataset.ldpNativeOldTopic; }); const controls = [...card.querySelectorAll('a,button,[role="button"]')]; const matchingControl = controls.find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰'); const fallback = matchingControl ? null : [...card.querySelectorAll('span')].find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰'); const dndControl = matchingControl || (fallback && (fallback.closest('a,button,[role="button"]') || fallback)); if (dndControl && dndControl.dataset.ldpNativeDnd !== 'true') dndControl.dataset.ldpNativeDnd = 'true'; syncNativeTopicTitleTools(card, dndControl); syncNativeTopicStatsComponent(card, reactionCounts); }); } function clearNativeEmbeddedTopicEnhancements(scope = document) { if (!scope || typeof scope.querySelectorAll !== 'function') return; scope.querySelectorAll('.ldp-topic-stats-component').forEach((component) => component.remove()); scope.querySelectorAll('.ldp-native-topic-title-tools').forEach((group) => { group.replaceWith(...Array.from(group.childNodes)); }); scope.querySelectorAll('[data-ldp-native-dnd]').forEach((node) => node.removeAttribute('data-ldp-native-dnd')); scope.querySelectorAll('[data-ldp-native-topic-date]').forEach((node) => node.removeAttribute('data-ldp-native-topic-date')); scope.querySelectorAll('[data-ldp-native-old-topic]').forEach((node) => node.removeAttribute('data-ldp-native-old-topic')); } function createReaderWorkspaceController(overlay, modal, isDirectTopicRoute) { const resizeHandle = modal.querySelector('.ldp-reader-embed-resize'); const hostScrollbar = overlay.querySelector('.ldp-reader-host-scrollbar'); const hostScrollbarThumb = hostScrollbar && hostScrollbar.querySelector('.ldp-reader-host-scrollbar-thumb'); const hostTopButton = overlay.querySelector('.ldp-reader-host-top'); const root = document.documentElement; const workspaceStyleNodes = [root, overlay]; const workspaceEventBindings = []; let mode = 'floating'; let pointer = null; let resizeFrame = 0; let embedWidth = currentListReaderEmbedWidth(); let destroyed = false; let embeddedHostRoots = new Map(); let embeddedHostObserver = null; let embeddedHostFrame = 0; let embeddedHostCardFrame = 0; const embeddedHostChangedCards = new Set(); const embeddedHostActivityCards = new Set(); let hostScrollbarFrame = 0; let hostScrollbarResizeObserver = null; let hostScrollbarPointer = null; let hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 }; let hostTopButtonTimer = 0; let hostTopCountdownTimer = 0; let hostScrollTop = 0; let hostUpwardDistance = 0; let hostUpwardStartedAt = 0; let hostLastScrollAt = 0; let hostPointerClientX = NaN; let hostPointerClientY = NaN; let hostTopJumping = false; let nativeListRowHeight = 0; const setWorkspaceStyle = (name, value) => { workspaceStyleNodes.forEach((node) => node.style.setProperty(name, value)); }; const clearWorkspaceStyle = (name) => { workspaceStyleNodes.forEach((node) => node.style.removeProperty(name)); }; const measureNativeListRowHeight = () => { const heights = [...document.querySelectorAll('tr.topic-list-item,.topic-list-item,.latest-topic-list-item')] .filter((node) => !node.closest('.ldp-overlay')) .map((node) => { const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height >= 48 && rect.height <= 320 ? Math.round(rect.height) : 0; }) .filter(Boolean) .sort((left, right) => left - right); return heights.length ? heights[Math.floor((heights.length - 1) / 2)] : 0; }; const setEmbedResizing = (active) => { root.classList.toggle('ldp-reader-embed-resizing', !!active); overlay.classList.toggle('ldp-reader-embed-resizing', !!active); }; const pageScrollMetrics = () => { const scrollingElement = document.scrollingElement || root; const viewportHeight = Math.max(1, Number(window.innerHeight || root.clientHeight || 0)); const scrollHeight = Math.max( viewportHeight, Number(scrollingElement && scrollingElement.scrollHeight || 0), Number(document.body && document.body.scrollHeight || 0), ); const maxScroll = Math.max(0, scrollHeight - viewportHeight); const scrollTop = Math.min(maxScroll, Math.max(0, Number( window.scrollY || scrollingElement && scrollingElement.scrollTop || 0, ))); return { viewportHeight, scrollHeight, maxScroll, scrollTop }; }; const syncHostScrollbar = () => { hostScrollbarFrame = 0; if (destroyed || !isEmbeddedListReaderMode(mode) || !hostScrollbar || !hostScrollbarThumb) return; const metrics = pageScrollMetrics(); const trackHeight = Math.max(1, hostScrollbar.clientHeight); const thumbHeight = metrics.maxScroll > 0 ? Math.min(trackHeight, Math.max(24, trackHeight * metrics.viewportHeight / metrics.scrollHeight)) : trackHeight; const maxThumbTop = Math.max(0, trackHeight - thumbHeight); const thumbTop = metrics.maxScroll > 0 ? maxThumbTop * metrics.scrollTop / metrics.maxScroll : 0; hostScrollbarState = { maxScroll: metrics.maxScroll, maxThumbTop, thumbHeight }; hostScrollbar.classList.toggle('ldp-reader-host-scrollbar-inactive', metrics.maxScroll <= 0); hostScrollbar.setAttribute('aria-disabled', String(metrics.maxScroll <= 0)); hostScrollbar.setAttribute('aria-valuemax', String(Math.round(metrics.maxScroll))); hostScrollbar.setAttribute('aria-valuenow', String(Math.round(metrics.scrollTop))); hostScrollbarThumb.style.height = `${Math.round(thumbHeight * 100) / 100}px`; hostScrollbarThumb.style.transform = `translateY(${Math.round(thumbTop * 100) / 100}px)`; }; const scheduleHostScrollbarSync = () => { if (hostScrollbarFrame || destroyed || !isEmbeddedListReaderMode(mode)) return; hostScrollbarFrame = requestAnimationFrame(syncHostScrollbar); }; const hideHostTopButton = () => { if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer); if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer); hostTopButtonTimer = 0; hostTopCountdownTimer = 0; if (hostTopButton) hostTopButton.hidden = true; }; const resetHostUpwardScroll = (scrollTop = pageScrollMetrics().scrollTop) => { hostScrollTop = scrollTop; hostUpwardDistance = 0; hostUpwardStartedAt = 0; hostLastScrollAt = 0; }; const hostHorizontalBounds = () => mode === 'embed-left' ? { left: embedWidth, right: window.innerWidth } : { left: 0, right: window.innerWidth - embedWidth }; const rememberHostPointer = (event) => { if (!isEmbeddedListReaderMode(mode) || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return; const bounds = hostHorizontalBounds(); if (event.clientX < bounds.left || event.clientX > bounds.right) return; hostPointerClientX = event.clientX; hostPointerClientY = event.clientY; }; const showHostTopButton = () => { if (!hostTopButton || !Number.isFinite(hostPointerClientX) || !Number.isFinite(hostPointerClientY)) return; const metrics = pageScrollMetrics(); if (metrics.scrollTop <= 8) { hideHostTopButton(); return; } if (hostTopButton.hidden) { const bounds = hostHorizontalBounds(); const buttonSize = 30; const edgeGap = 8; const pointerGap = 12; const left = Math.max(bounds.left + edgeGap, Math.min( bounds.right - buttonSize - edgeGap, hostPointerClientX + pointerGap, )); const top = Math.max(edgeGap, Math.min( window.innerHeight - buttonSize - edgeGap, hostPointerClientY - buttonSize / 2, )); hostTopButton.style.left = `${Math.round(left)}px`; hostTopButton.style.top = `${Math.round(top)}px`; hostTopButton.hidden = false; } if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer); if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer); const countdown = hostTopButton.querySelector('.ldp-reader-host-top-countdown'); const hideAt = performance.now() + 3000; const updateCountdown = () => { if (countdown) countdown.textContent = String(Math.max(1, Math.ceil((hideAt - performance.now()) / 1000))); }; updateCountdown(); hostTopCountdownTimer = setInterval(updateCountdown, 200); hostTopButtonTimer = setTimeout(hideHostTopButton, 3000); }; const onHostScroll = () => { scheduleHostScrollbarSync(); if (!isEmbeddedListReaderMode(mode)) return; const scrollTop = pageScrollMetrics().scrollTop; if (hostTopJumping) { resetHostUpwardScroll(scrollTop); return; } const now = performance.now(); const upwardDelta = hostScrollTop - scrollTop; hostScrollTop = scrollTop; if (scrollTop <= 8) hideHostTopButton(); if (upwardDelta <= 0) { hostUpwardDistance = 0; hostUpwardStartedAt = 0; hostLastScrollAt = now; return; } if (!hostUpwardStartedAt || now - hostLastScrollAt > 240) { hostUpwardStartedAt = now; hostUpwardDistance = 0; } hostLastScrollAt = now; hostUpwardDistance += upwardDelta; if (hostUpwardDistance >= 240 && now - hostUpwardStartedAt <= 450) { showHostTopButton(); hostUpwardStartedAt = now; hostUpwardDistance = 0; } }; const onHostTopClick = () => { if (!isEmbeddedListReaderMode(mode)) return; hideHostTopButton(); hostTopJumping = true; window.scrollTo({ top: 0, behavior: 'auto' }); requestAnimationFrame(() => { hostTopJumping = false; resetHostUpwardScroll(0); }); }; const startHostScrollbarSync = () => { if (!hostScrollbar || !hostScrollbarThumb) return; if (!hostScrollbarResizeObserver && typeof ResizeObserver === 'function') { hostScrollbarResizeObserver = new ResizeObserver(scheduleHostScrollbarSync); hostScrollbarResizeObserver.observe(root); if (document.body) hostScrollbarResizeObserver.observe(document.body); } resetHostUpwardScroll(); scheduleHostScrollbarSync(); }; const stopHostScrollbarSync = () => { if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame); hostScrollbarFrame = 0; if (hostScrollbarResizeObserver) hostScrollbarResizeObserver.disconnect(); hostScrollbarResizeObserver = null; hostScrollbarPointer = null; hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 }; hideHostTopButton(); resetHostUpwardScroll(0); if (!hostScrollbar || !hostScrollbarThumb) return; hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging'); hostScrollbar.classList.add('ldp-reader-host-scrollbar-inactive'); hostScrollbar.setAttribute('aria-disabled', 'true'); hostScrollbar.setAttribute('aria-valuemax', '0'); hostScrollbar.setAttribute('aria-valuenow', '0'); hostScrollbarThumb.style.removeProperty('height'); hostScrollbarThumb.style.removeProperty('transform'); }; const scrollHostFromPointer = (clientY) => { if (!hostScrollbar || !hostScrollbarPointer || hostScrollbarState.maxScroll <= 0) return; const rect = hostScrollbar.getBoundingClientRect(); const thumbTop = Math.max(0, Math.min( hostScrollbarState.maxThumbTop, clientY - rect.top - hostScrollbarPointer.offsetY, )); const ratio = hostScrollbarState.maxThumbTop > 0 ? thumbTop / hostScrollbarState.maxThumbTop : 0; window.scrollTo({ top: ratio * hostScrollbarState.maxScroll, behavior: 'auto' }); }; const onHostScrollbarPointerDown = (event) => { if (event.button !== 0 || !isEmbeddedListReaderMode(mode) || !hostScrollbar || !hostScrollbarThumb) return; if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame); hostScrollbarFrame = 0; syncHostScrollbar(); if (hostScrollbarState.maxScroll <= 0) return; event.preventDefault(); event.stopPropagation(); const thumbRect = hostScrollbarThumb.getBoundingClientRect(); const onThumb = event.target === hostScrollbarThumb; hostScrollbarPointer = { pointerId: event.pointerId, offsetY: onThumb ? event.clientY - thumbRect.top : hostScrollbarState.thumbHeight / 2, }; hostScrollbar.classList.add('ldp-reader-host-scrollbar-dragging'); hostScrollbar.setPointerCapture(event.pointerId); try { hostScrollbar.focus({ preventScroll: true }); } catch (e) { hostScrollbar.focus(); } scrollHostFromPointer(event.clientY); }; const onHostScrollbarPointerMove = (event) => { if (!hostScrollbarPointer || event.pointerId !== hostScrollbarPointer.pointerId) return; event.preventDefault(); scrollHostFromPointer(event.clientY); }; const stopHostScrollbarPointer = (event) => { if (!hostScrollbarPointer || event && event.pointerId !== hostScrollbarPointer.pointerId) return; const pointerId = hostScrollbarPointer.pointerId; hostScrollbarPointer = null; if (hostScrollbar) { hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging'); try { if (hostScrollbar.hasPointerCapture(pointerId)) hostScrollbar.releasePointerCapture(pointerId); } catch (e) {} } }; const onHostScrollbarKeyDown = (event) => { if (!isEmbeddedListReaderMode(mode)) return; const metrics = pageScrollMetrics(); let nextTop = metrics.scrollTop; if (event.key === 'ArrowUp') nextTop -= 40; else if (event.key === 'ArrowDown') nextTop += 40; else if (event.key === 'PageUp') nextTop -= metrics.viewportHeight * .9; else if (event.key === 'PageDown') nextTop += metrics.viewportHeight * .9; else if (event.key === 'Home') nextTop = 0; else if (event.key === 'End') nextTop = metrics.maxScroll; else return; event.preventDefault(); event.stopPropagation(); window.scrollTo({ top: Math.max(0, Math.min(metrics.maxScroll, nextTop)), behavior: 'auto' }); }; const topLevelBodyChild = (node) => { let current = node && node.nodeType === 1 ? node : null; while (current && current.parentElement && current.parentElement !== document.body) current = current.parentElement; return current && current.parentElement === document.body ? current : null; }; const syncEmbeddedHostRoots = () => { embeddedHostFrame = 0; if (destroyed || !document.body || !isEmbeddedListReaderMode(mode)) return; const nextRoots = new Map(); const rank = { sidebar: 1, header: 2, shell: 3 }; const mark = (node, role) => { const topLevel = topLevelBodyChild(node); if (!topLevel || topLevel === overlay || topLevel.contains(overlay)) return; const previousRole = nextRoots.get(topLevel); if (!previousRole || rank[role] > rank[previousRole]) nextRoots.set(topLevel, role); }; document.querySelectorAll('#ember-app,#main-outlet-wrapper,#main-outlet,.list-container,.topic-list,.navigation-container').forEach((node) => mark(node, 'shell')); document.querySelectorAll('.d-header,body > header').forEach((node) => mark(node, 'header')); document.querySelectorAll('#d-sidebar,.sidebar-wrapper,.sidebar-container').forEach((node) => mark(node, 'sidebar')); for (const node of embeddedHostRoots.keys()) { if (!nextRoots.has(node)) node.removeAttribute('data-ldp-reader-host-root'); } nextRoots.forEach((role, node) => { node.setAttribute('data-ldp-reader-host-root', role); if (role === 'shell') markNativeDoNotDisturbBadges(node); }); embeddedHostRoots = nextRoots; }; const scheduleEmbeddedHostRootSync = () => { if (embeddedHostFrame || destroyed || !isEmbeddedListReaderMode(mode)) return; embeddedHostFrame = requestAnimationFrame(syncEmbeddedHostRoots); }; const flushEmbeddedHostCards = () => { embeddedHostCardFrame = 0; if (destroyed || !isEmbeddedListReaderMode(mode)) { embeddedHostChangedCards.clear(); embeddedHostActivityCards.clear(); return; } const activityCards = Array.from(embeddedHostActivityCards) .filter((card) => card.isConnected && !embeddedHostChangedCards.has(card)); embeddedHostActivityCards.clear(); activityCards.forEach((card) => { if (!syncNativeTopicActivityStat(card)) embeddedHostChangedCards.add(card); }); const cards = Array.from(embeddedHostChangedCards).filter((card) => card.isConnected); embeddedHostChangedCards.clear(); if (!cards.length) return; const reactionCountsByTopicId = nativeTopicOpReactionCounts(); cards.forEach((card) => markNativeDoNotDisturbBadges(card, reactionCountsByTopicId)); }; const scheduleEmbeddedHostCards = (cards) => { cards.forEach((card) => embeddedHostChangedCards.add(card)); if (!embeddedHostChangedCards.size || embeddedHostCardFrame || destroyed || !isEmbeddedListReaderMode(mode)) return; embeddedHostCardFrame = requestAnimationFrame(flushEmbeddedHostCards); }; const scheduleEmbeddedHostActivityCards = (cards) => { cards.forEach((card) => embeddedHostActivityCards.add(card)); if (!embeddedHostActivityCards.size || embeddedHostCardFrame || destroyed || !isEmbeddedListReaderMode(mode)) return; embeddedHostCardFrame = requestAnimationFrame(flushEmbeddedHostCards); }; const startEmbeddedHostRootObserver = () => { if (!embeddedHostObserver && document.body) { embeddedHostObserver = new MutationObserver((records) => { const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item'; const changedCards = new Set(); const collectNearestCard = (node) => { const element = node && (node.nodeType === 1 ? node : node.parentElement); if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return; const card = element.matches(cardSelector) ? element : element.closest(cardSelector); if (card) changedCards.add(card); }; const collectAddedCards = (node) => { const element = node && (node.nodeType === 1 ? node : node.parentElement); if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return; const card = element.matches(cardSelector) ? element : element.closest(cardSelector); if (card) { changedCards.add(card); return; } if (element.querySelectorAll) element.querySelectorAll(cardSelector).forEach((item) => changedCards.add(item)); }; records.forEach((record) => { const activityTarget = record.type === 'childList' && record.target.nodeType === 1 && record.target.matches('.relative-date') && record.target.closest('td.activity'); const onlyActivityTextChanged = activityTarget && record.addedNodes.length > 0 && record.removedNodes.length > 0 && [...record.addedNodes, ...record.removedNodes].every((node) => node.nodeType === 3); if (onlyActivityTextChanged) { const card = activityTarget.closest(cardSelector); if (card) scheduleEmbeddedHostActivityCards([card]); return; } if (record.type === 'childList' && record.target === document.body) scheduleEmbeddedHostRootSync(); const changedNodes = record.type === 'childList' ? [...record.addedNodes, ...record.removedNodes] : []; const onlyOwnedStatsChanged = changedNodes.length > 0 && changedNodes.every((node) => node.nodeType === 1 && (node.matches('.ldp-topic-stats-component') || node.closest('.ldp-topic-stats-component')) ); if (!onlyOwnedStatsChanged) collectNearestCard(record.target); record.addedNodes && record.addedNodes.forEach(collectAddedCards); }); scheduleEmbeddedHostCards(changedCards); }); embeddedHostObserver.observe(document.body, { childList: true, subtree: true, characterData: true }); } syncEmbeddedHostRoots(); }; const clearEmbeddedHostRoots = () => { if (embeddedHostFrame) cancelAnimationFrame(embeddedHostFrame); if (embeddedHostCardFrame) cancelAnimationFrame(embeddedHostCardFrame); embeddedHostFrame = 0; embeddedHostCardFrame = 0; embeddedHostChangedCards.clear(); embeddedHostActivityCards.clear(); if (embeddedHostObserver) embeddedHostObserver.disconnect(); embeddedHostObserver = null; clearNativeEmbeddedTopicEnhancements(); for (const node of embeddedHostRoots.keys()) node.removeAttribute('data-ldp-reader-host-root'); embeddedHostRoots = new Map(); }; const clearEmbeddedHost = () => { setEmbedResizing(false); stopHostScrollbarPointer(); stopHostScrollbarSync(); clearEmbeddedHostRoots(); root.classList.remove('ldp-reader-embedded-left', 'ldp-reader-embedded-right'); if (!root.classList.contains('ldp-reader-sidepanel-active')) root.classList.remove('ldp-reader-workspace'); clearWorkspaceStyle('--ldp-reader-workspace-width'); delete overlay.dataset.readerWorkspaceWidth; clearWorkspaceStyle('--ldp-reader-host-min-width'); clearWorkspaceStyle('--ldp-reader-native-row-height'); nativeListRowHeight = 0; root.style.removeProperty('--ldp-reader-list-zebra-color'); root.style.removeProperty('--ldp-divider-line-color'); root.style.removeProperty('--ldp-divider-line-width'); document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((card) => card.removeAttribute('data-ldp-reader-active-topic')); }; const maximumEmbedWidth = () => Math.max(READER_EMBED_MIN_WIDTH, Math.floor(window.innerWidth - READER_HOST_MIN_WIDTH)); const canEmbed = () => window.innerWidth >= READER_EMBED_MIN_WIDTH + READER_HOST_MIN_WIDTH; const clampEmbedWidth = (value) => Math.min(maximumEmbedWidth(), Math.max(READER_EMBED_MIN_WIDTH, Math.round(value))); const setEmbedWidth = (value) => { embedWidth = clampEmbedWidth(value); overlay.dataset.readerWorkspaceWidth = String(embedWidth); setWorkspaceStyle('--ldp-reader-workspace-width', `${embedWidth}px`); return embedWidth; }; const effectiveMode = (requested) => { if (isDirectTopicRoute || !LIST_READER_MODES.includes(requested)) return 'floating'; if (!isEmbeddedListReaderMode(requested)) return requested; return canEmbed() ? requested : 'floating'; }; const apply = (requested = mode) => { const previousMode = mode; mode = effectiveMode(requested); const embedded = isEmbeddedListReaderMode(mode); if (isEmbeddedListReaderMode(previousMode) && !embedded) { stopHostScrollbarPointer(); stopHostScrollbarSync(); } overlay.dataset.readerWorkspaceMode = mode; overlay.classList.toggle('ldp-reader-embedded', embedded); overlay.classList.toggle('ldp-reader-embedded-left', mode === 'embed-left'); overlay.classList.toggle('ldp-reader-embedded-right', mode === 'embed-right'); if (!embedded) { clearEmbeddedHost(); return mode; } ensureReaderStyleMounted(); if (!root.classList.contains('ldp-reader-workspace')) nativeListRowHeight = measureNativeListRowHeight(); const width = currentListReaderEmbedWidth(); root.classList.add('ldp-reader-workspace'); root.classList.toggle('ldp-reader-embedded-left', mode === 'embed-left'); root.classList.toggle('ldp-reader-embedded-right', mode === 'embed-right'); setEmbedWidth(width); setWorkspaceStyle('--ldp-reader-host-min-width', `${READER_HOST_MIN_WIDTH}px`); if (nativeListRowHeight) setWorkspaceStyle('--ldp-reader-native-row-height', `${nativeListRowHeight}px`); const appearance = currentAppearanceProfile(); root.style.setProperty('--ldp-reader-list-zebra-color', resolvedReaderThemeMode() === 'dark' && appearance.listZebraColor === LIST_ZEBRA_COLOR_DEFAULT ? READER_THEME_VARIABLES.dark['--ldp-zebra-color'] : appearance.listZebraColor); if (appearance.dividerLineColor === DIVIDER_LINE_COLOR_DEFAULT) root.style.removeProperty('--ldp-divider-line-color'); else root.style.setProperty('--ldp-divider-line-color', appearance.dividerLineColor); if (appearance.dividerLineWidth === DIVIDER_LINE_WIDTH_DEFAULT) root.style.removeProperty('--ldp-divider-line-width'); else root.style.setProperty('--ldp-divider-line-width', `${appearance.dividerLineWidth}px`); startEmbeddedHostRootObserver(); startHostScrollbarSync(); return mode; }; const emitChange = () => { if (!destroyed) overlay.dispatchEvent(new CustomEvent('ldp-reader-workspace-change')); }; const setMode = (nextMode) => { if (isDirectTopicRoute || !LIST_READER_MODES.includes(nextMode)) return false; setPrefs({ listReaderMode: nextMode }); apply(nextMode); emitChange(); return true; }; const applyResizePreview = () => { resizeFrame = 0; if (!pointer || !isEmbeddedListReaderMode(mode)) return; const width = mode === 'embed-left' ? pointer.clientX : window.innerWidth - pointer.clientX; setEmbedWidth(width); }; const onResizePointerMove = (event) => { if (!pointer || event.pointerId !== pointer.pointerId) return; pointer.clientX = event.clientX; if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResizePreview); }; const stopResize = (event) => { if (!pointer || (event && event.pointerId !== pointer.pointerId)) { setEmbedResizing(false); return; } if (resizeFrame) { cancelAnimationFrame(resizeFrame); resizeFrame = 0; applyResizePreview(); } setEmbedResizing(false); setPref('listReaderEmbedWidth', embedWidth); try { if (resizeHandle && resizeHandle.hasPointerCapture(pointer.pointerId)) resizeHandle.releasePointerCapture(pointer.pointerId); } catch (e) {} pointer = null; emitChange(); }; const onResizePointerDown = (event) => { if (event.button !== 0 || !isEmbeddedListReaderMode(mode)) return; event.preventDefault(); event.stopPropagation(); pointer = { pointerId: event.pointerId, clientX: event.clientX }; setEmbedResizing(true); resizeHandle.setPointerCapture(event.pointerId); }; const onViewportResize = () => { const requested = isDirectTopicRoute ? 'floating' : currentListReaderMode(); apply(requested); emitChange(); }; if (resizeHandle) { addTrackedEventListener(workspaceEventBindings, resizeHandle, 'pointerdown', onResizePointerDown); addTrackedEventListener(workspaceEventBindings, resizeHandle, 'pointermove', onResizePointerMove); addTrackedEventListener(workspaceEventBindings, resizeHandle, 'pointerup', stopResize); addTrackedEventListener(workspaceEventBindings, resizeHandle, 'pointercancel', stopResize); } addTrackedEventListener(workspaceEventBindings, window, 'resize', onViewportResize); addTrackedEventListener(workspaceEventBindings, window, 'pointermove', rememberHostPointer, { passive: true }); addTrackedEventListener(workspaceEventBindings, window, 'wheel', rememberHostPointer, { capture: true, passive: true }); addTrackedEventListener(workspaceEventBindings, window, 'scroll', onHostScroll, { passive: true }); if (hostTopButton) addTrackedEventListener(workspaceEventBindings, hostTopButton, 'click', onHostTopClick); if (hostScrollbar) { addTrackedEventListener(workspaceEventBindings, hostScrollbar, 'pointerdown', onHostScrollbarPointerDown); addTrackedEventListener(workspaceEventBindings, hostScrollbar, 'pointermove', onHostScrollbarPointerMove); addTrackedEventListener(workspaceEventBindings, hostScrollbar, 'pointerup', stopHostScrollbarPointer); addTrackedEventListener(workspaceEventBindings, hostScrollbar, 'pointercancel', stopHostScrollbarPointer); addTrackedEventListener(workspaceEventBindings, hostScrollbar, 'keydown', onHostScrollbarKeyDown); } apply(isDirectTopicRoute ? 'floating' : currentListReaderMode()); return { mode: () => mode, isEmbedded: () => isEmbeddedListReaderMode(mode), dimensions: () => ({ width: isEmbeddedListReaderMode(mode) ? embedWidth : modal.clientWidth, height: modal.clientHeight || window.innerHeight, }), setMode, sync: onViewportResize, destroy() { if (destroyed) return; destroyed = true; if (resizeFrame) cancelAnimationFrame(resizeFrame); stopResize(); clearEmbeddedHost(); removeTrackedEventListeners(workspaceEventBindings); }, }; } function createNativeComposerWindowController(readerSession) { const root = document.documentElement; const rootStyle = root.style; const geometryProperties = [ '--ldp-composer-left', '--ldp-composer-top', '--ldp-composer-width', '--ldp-composer-height', '--ldp-composer-transform', ]; const positionedAttribute = 'data-ldp-reader-composer-positioned'; const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; let composer = null; let composerRoot = null; let composerOverflowLayers = []; let composerOverflowFrame = 0; let chrome = null; let composerObserver = null; let composerHostObserver = null; let state = null; let stateProfile = ''; let pointer = null; let pointerFrame = 0; let pointerClientX = 0; let pointerClientY = 0; let chromeFrame = 0; let statePersistTimer = 0; let nativeFloatingFrame = 0; let nativeFloatingObserver = null; const ownedTopLayerElements = new Set(); const nativeFloatingSurfaceSelector = '.fk-d-menu,.emoji-picker'; let destroyed = false; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const numeric = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback; const cssPixels = (value) => `${Math.round(value * 100) / 100}px`; const sessionActive = () => !destroyed && (!readerSession || readerSession.isCurrent('opening', 'active')); const desktopWindow = () => window.innerWidth > 760; const elementInTopLayer = (element) => { try { return !!(element && element.matches(':popover-open')); } catch (error) { return false; } }; const releaseOwnedTopLayer = (element) => { if (!element || !ownedTopLayerElements.delete(element)) return; try { if (elementInTopLayer(element) && typeof element.hidePopover === 'function') element.hidePopover(); } catch (error) {} element.removeAttribute('popover'); delete element.dataset.ldpReaderTopLayer; }; const promoteOwnedTopLayer = (element, type) => { if (!element || typeof element.showPopover !== 'function') return false; if (element.hasAttribute('popover') && !ownedTopLayerElements.has(element)) return elementInTopLayer(element); if (!ownedTopLayerElements.has(element)) { element.setAttribute('popover', 'manual'); element.dataset.ldpReaderTopLayer = type; ownedTopLayerElements.add(element); } try { if (!elementInTopLayer(element)) element.showPopover(); return elementInTopLayer(element); } catch (error) { releaseOwnedTopLayer(element); return false; } }; const nativeFloatingSurfaceVisible = (element) => { if (!element || !element.isConnected || element.closest('#reply-control') || element.contains(composer) || element.closest('dialog[open]')) return false; if (element.classList.contains('closed')) return false; if (!composerElementAvailable(element)) return false; const elementStyle = getComputedStyle(element); if (elementStyle.display === 'none' || elementStyle.visibility === 'hidden') return false; const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }; const syncNativeFloatingTopLayers = () => { nativeFloatingFrame = 0; if (!elementInTopLayer(composer)) return; const visible = Array.from(document.querySelectorAll(nativeFloatingSurfaceSelector)) .filter(nativeFloatingSurfaceVisible); const surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element))); ownedTopLayerElements.forEach((element) => { if (element.dataset.ldpReaderTopLayer === 'portal' && !surfaces.includes(element)) releaseOwnedTopLayer(element); }); surfaces.forEach((element) => promoteOwnedTopLayer(element, 'portal')); }; const scheduleNativeFloatingTopLayerSync = () => { if (nativeFloatingFrame) return; nativeFloatingFrame = requestAnimationFrame(syncNativeFloatingTopLayers); }; const nativeFloatingMutationNodeIsRelevant = (node) => { const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement); if (!element) return false; return element.matches(nativeFloatingSurfaceSelector) || !!element.closest(nativeFloatingSurfaceSelector) || !!element.querySelector(nativeFloatingSurfaceSelector); }; const nativeFloatingMutationIsRelevant = (mutation) => { if (mutation.type === 'attributes') return nativeFloatingMutationNodeIsRelevant(mutation.target); return [...mutation.addedNodes, ...mutation.removedNodes].some(nativeFloatingMutationNodeIsRelevant); }; const startNativeFloatingTopLayerSync = () => { if (!nativeFloatingObserver && document.body) { nativeFloatingObserver = new MutationObserver((mutations) => { if (mutations.some(nativeFloatingMutationIsRelevant)) scheduleNativeFloatingTopLayerSync(); }); nativeFloatingObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open'], }); } scheduleNativeFloatingTopLayerSync(); }; const releaseOwnedComposerTopLayers = () => { if (nativeFloatingFrame) cancelAnimationFrame(nativeFloatingFrame); nativeFloatingFrame = 0; if (nativeFloatingObserver) nativeFloatingObserver.disconnect(); nativeFloatingObserver = null; Array.from(ownedTopLayerElements).reverse().forEach(releaseOwnedTopLayer); }; const releaseComposerTopLayers = () => { releaseOwnedComposerTopLayers(); root.classList.remove('ldp-composer-host-isolated'); }; const syncComposerTopLayers = () => { const composerOpen = !!(composer && composer.isConnected && !composer.classList.contains('closed') && composerElementAvailable(composer)); root.classList.toggle('ldp-composer-host-isolated', composerOpen); if (!composerOpen || !promoteOwnedTopLayer(composer, 'composer')) { releaseOwnedComposerTopLayers(); return false; } if (chrome && !chrome.hidden) promoteOwnedTopLayer(chrome, 'chrome'); startNativeFloatingTopLayerSync(); return true; }; const clearComposerRoot = () => { if (composerRoot) composerRoot.removeAttribute('data-ldp-reader-composer-root'); composerRoot = null; }; const syncComposerRoot = () => { let next = composer; while (next && next.parentElement && next.parentElement !== document.body) next = next.parentElement; if (!next || next.parentElement !== document.body) next = null; if (next === composerRoot) return; clearComposerRoot(); composerRoot = next; if (composerRoot) composerRoot.setAttribute('data-ldp-reader-composer-root', '1'); }; const clearComposerOverflowLayers = () => { if (composerOverflowFrame) cancelAnimationFrame(composerOverflowFrame); composerOverflowFrame = 0; composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow')); composerOverflowLayers = []; }; const syncComposerOverflowLayers = () => { composerOverflowFrame = 0; composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow')); composerOverflowLayers = []; if (!composer || !composer.isConnected || composer.classList.contains('closed')) return; const composerRect = composer.getBoundingClientRect(); if (composerRect.width <= 0 || composerRect.height <= 0) return; const candidates = [composer, ...composer.querySelectorAll('*')].map((candidate) => { if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1) return false; if (candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return false; const overflowX = getComputedStyle(candidate).overflowX; if (overflowX !== 'auto' && overflowX !== 'scroll') return false; const rect = candidate.getBoundingClientRect(); const coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left)); const coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top)); const widthCoverage = coveredWidth / composerRect.width; const heightCoverage = coveredHeight / composerRect.height; if (widthCoverage < .85 || heightCoverage < .75) return false; return { candidate, coverage: widthCoverage * heightCoverage }; }).filter(Boolean).sort((left, right) => right.coverage - left.coverage); composerOverflowLayers = candidates.length ? [candidates[0].candidate] : []; composerOverflowLayers.forEach((layer) => layer.setAttribute('data-ldp-reader-composer-overflow', '1')); }; const scheduleComposerOverflowSync = () => { if (composerOverflowFrame) return; composerOverflowFrame = requestAnimationFrame(syncComposerOverflowLayers); }; const limits = () => { const margin = Math.max(0, Math.min( 16, Math.floor((window.innerWidth - 1) / 2), Math.floor((window.innerHeight - 1) / 2), )); const left = margin; const top = margin; const right = window.innerWidth - margin; const bottom = window.innerHeight - margin; const maxWidth = Math.max(1, right - left); const maxHeight = Math.max(1, bottom - top); return { left, top, right, bottom, maxWidth, maxHeight, minWidth: Math.min(520, maxWidth), minHeight: Math.min(300, maxHeight), }; }; const defaultState = () => { const bounds = limits(); const width = Math.min(1200, bounds.maxWidth); const height = clamp(bounds.maxHeight * .7, bounds.minHeight, bounds.maxHeight); return { left: bounds.left + (bounds.maxWidth - width) / 2, top: bounds.top + (bounds.maxHeight - height) / 2, width, height, }; }; const normalizeState = (next) => { const bounds = limits(); const fallback = defaultState(); const width = clamp(numeric(next && next.width, fallback.width), bounds.minWidth, bounds.maxWidth); const height = clamp(numeric(next && next.height, fallback.height), bounds.minHeight, bounds.maxHeight); return { left: clamp(numeric(next && next.left, fallback.left), bounds.left, bounds.right - width), top: clamp(numeric(next && next.top, fallback.top), bounds.top, bounds.bottom - height), width, height, }; }; const activeStateProfile = () => { const profile = currentReaderProfileName(); return READER_PROFILE_KEYS.includes(profile) ? profile : 'full'; }; const preferredState = (profile = activeStateProfile()) => { const fallback = defaultState(); const prefix = COMPOSER_WINDOW_PROFILE_PREFIXES[profile]; return normalizeState({ left: PREFS[`${prefix}X`] || fallback.left, top: PREFS[`${prefix}Y`] || fallback.top, width: PREFS[`${prefix}Width`] || fallback.width, height: PREFS[`${prefix}Height`] || fallback.height, }); }; const syncFont = (fontProfile = currentFontProfile(currentReaderProfileName())) => { const values = normalizeFontProfile(fontProfile, FONT_PROFILE_DEFAULTS[currentReaderProfileName()]); if (!state || !composer) { if (composer) { composer.style.removeProperty('--ldp-composer-font-size'); clearInterfaceFontScale(composer.style); clearReaderFontAppearance(composer.style); } applyComposerFontScale(); return; } const bounds = limits(); const widthTarget = Math.min(980, bounds.maxWidth); const heightTarget = Math.min(720, bounds.maxHeight); const widthProgress = clamp((state.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth), 0, 1); const heightProgress = clamp((state.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight), 0, 1); const automaticPixels = 12 + Math.min(widthProgress, heightProgress) * 4; const preferenceRatio = normalizeFontScale(values.composer, COMPOSER_FONT_SCALE_DEFAULT) / COMPOSER_FONT_SCALE_DEFAULT; const pixels = clamp(automaticPixels * preferenceRatio, 8, 40); applyInterfaceFontScale(composer.style, values.interface); applyReaderFontAppearance(composer.style, values); composer.style.setProperty('--ldp-composer-font-size', `${Math.round(pixels * 100) / 100}px`); }; const syncChromeNow = (syncSize = true) => { chromeFrame = 0; if (!chrome) return; const available = sessionActive() && state && desktopWindow() && composerElementAvailable(composer) && !composer.classList.contains('closed') && !composer.classList.contains('fullscreen'); chrome.hidden = !available; if (!available) return; if (syncSize) { chrome.style.setProperty('--ldp-composer-chrome-width', cssPixels(state.width)); chrome.style.setProperty('--ldp-composer-chrome-height', cssPixels(state.height)); } chrome.style.transform = `translate3d(${cssPixels(state.left)},${cssPixels(state.top)},0)`; }; const scheduleChromeSync = () => { if (chromeFrame) return; chromeFrame = requestAnimationFrame(syncChromeNow); }; const applyState = (next) => { const previous = state; state = normalizeState(next); if (!composer) return; const composerStyle = composer.style; const sizeChanged = !previous || previous.width !== state.width || previous.height !== state.height; if (!previous) { composerStyle.setProperty('--ldp-composer-left', '0px'); composerStyle.setProperty('--ldp-composer-top', '0px'); composerStyle.setProperty('--ldp-composer-transform', 'none'); } if (sizeChanged) { composerStyle.setProperty('--ldp-composer-width', cssPixels(state.width)); composerStyle.setProperty('--ldp-composer-height', cssPixels(state.height)); } if (desktopWindow() && !composer.classList.contains('fullscreen')) { composerStyle.setProperty('translate', `${cssPixels(state.left)} ${cssPixels(state.top)}`); } else { composerStyle.removeProperty('translate'); } if (sizeChanged) syncFont(); if (chromeFrame) cancelAnimationFrame(chromeFrame); syncChromeNow(sizeChanged); composer.setAttribute(positionedAttribute, '1'); }; const persistState = () => { if (statePersistTimer) clearTimeout(statePersistTimer); statePersistTimer = 0; if (!state || !stateProfile || !desktopWindow()) return; const prefix = COMPOSER_WINDOW_PROFILE_PREFIXES[stateProfile]; setPrefs({ [`${prefix}Width`]: Math.round(state.width), [`${prefix}Height`]: Math.round(state.height), [`${prefix}X`]: Math.round(state.left), [`${prefix}Y`]: Math.round(state.top), }); }; const scheduleStatePersist = () => { if (statePersistTimer) clearTimeout(statePersistTimer); statePersistTimer = setTimeout(persistState, 160); }; const syncStateProfile = () => { const nextProfile = activeStateProfile(); if (!state) { stateProfile = nextProfile; applyState(preferredState(nextProfile)); return; } if (nextProfile === stateProfile) { applyState(state); return; } persistState(); stateProfile = nextProfile; applyState(preferredState(nextProfile)); }; const clearGeometry = () => { const composerStyle = composer && composer.style; if (composer) composer.removeAttribute(positionedAttribute); geometryProperties.forEach((property) => { rootStyle.removeProperty(property); if (composerStyle) composerStyle.removeProperty(property); }); if (composerStyle) { composerStyle.removeProperty('--ldp-composer-font-size'); clearInterfaceFontScale(composerStyle); clearReaderFontAppearance(composerStyle); composerStyle.removeProperty('translate'); } applyComposerFontScale(); }; const stopPointer = (event) => { if (!pointer) { if (chrome) chrome.classList.remove('is-interacting'); root.classList.remove('ldp-composer-window-interacting'); return; } if (event && event.pointerId !== pointer.id) return; if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) { pointerClientX = event.clientX; pointerClientY = event.clientY; } if (pointerFrame) cancelAnimationFrame(pointerFrame); pointerFrame = 0; applyPointerPosition(); const activePointer = pointer; pointer = null; const target = activePointer.target; if (target && target.hasPointerCapture(activePointer.id)) target.releasePointerCapture(activePointer.id); if (chrome) chrome.classList.remove('is-interacting'); root.classList.remove('ldp-composer-window-interacting'); scheduleComposerOverflowSync(); persistState(); }; const resizeState = (start, direction, deltaX, deltaY) => { const bounds = limits(); let left = start.left; let top = start.top; let right = start.left + start.width; let bottom = start.top + start.height; if (direction.includes('w')) left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth); if (direction.includes('e')) right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right); if (direction.includes('n')) top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight); if (direction.includes('s')) bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom); return { left, top, width: right - left, height: bottom - top }; }; function applyPointerPosition() { if (!pointer) return; const deltaX = pointerClientX - pointer.startX; const deltaY = pointerClientY - pointer.startY; if (pointer.mode === 'move') { const bounds = limits(); applyState({ left: clamp(pointer.state.left + deltaX, bounds.left, bounds.right - pointer.state.width), top: clamp(pointer.state.top + deltaY, bounds.top, bounds.bottom - pointer.state.height), width: pointer.state.width, height: pointer.state.height, }); } else { applyState(resizeState(pointer.state, pointer.mode, deltaX, deltaY)); } } const movePointer = (event) => { if (!pointer || event.pointerId !== pointer.id) return; pointerClientX = event.clientX; pointerClientY = event.clientY; if (!pointerFrame) { pointerFrame = requestAnimationFrame(() => { pointerFrame = 0; applyPointerPosition(); }); } event.preventDefault(); }; const startPointer = (event, mode) => { if (!state || !desktopWindow() || !nativeComposerVisible(composer) || composer.classList.contains('fullscreen')) return; if (event.button !== 0) return; pointer = { id: event.pointerId, mode, startX: event.clientX, startY: event.clientY, state: Object.assign({}, state), target: event.currentTarget, }; pointerClientX = event.clientX; pointerClientY = event.clientY; event.currentTarget.setPointerCapture(event.pointerId); chrome.classList.add('is-interacting'); root.classList.add('ldp-composer-window-interacting'); event.preventDefault(); event.stopPropagation(); }; const ensureChrome = () => { if (chrome) return chrome; chrome = document.createElement('div'); chrome.className = 'ldp-composer-window-chrome sciapp-ldp-owned'; chrome.hidden = true; chrome.innerHTML = `<button class="ldp-composer-drag-handle" type="button" aria-label="拖动回复窗口;方向键可微调位置"></button>${resizeDirections.map((direction) => direction === 'se' ? '<button class="ldp-composer-resize-handle" data-resize="se" type="button" aria-label="拖动调整回复窗口大小;方向键可微调"></button>' : `<span class="ldp-composer-resize-handle" data-resize="${direction}" aria-hidden="true"></span>`).join('')}`; appendReaderPortalNode(chrome); const dragHandle = chrome.querySelector('.ldp-composer-drag-handle'); dragHandle.addEventListener('pointerdown', (event) => startPointer(event, 'move')); dragHandle.addEventListener('keydown', (event) => { if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; const step = event.shiftKey ? 40 : 12; const next = Object.assign({}, state); if (event.key === 'ArrowLeft') next.left -= step; else if (event.key === 'ArrowRight') next.left += step; else if (event.key === 'ArrowUp') next.top -= step; else next.top += step; applyState(next); scheduleStatePersist(); event.preventDefault(); }); chrome.querySelectorAll('[data-resize]').forEach((handle) => { handle.addEventListener('pointerdown', (event) => startPointer(event, handle.dataset.resize)); }); const resizeGrip = chrome.querySelector('[data-resize="se"]'); resizeGrip.addEventListener('keydown', (event) => { if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; const step = event.shiftKey ? 40 : 12; const deltaX = event.key === 'ArrowLeft' ? -step : (event.key === 'ArrowRight' ? step : 0); const deltaY = event.key === 'ArrowUp' ? -step : (event.key === 'ArrowDown' ? step : 0); applyState(resizeState(state, 'se', deltaX, deltaY)); scheduleStatePersist(); event.preventDefault(); }); chrome.addEventListener('pointermove', movePointer); chrome.addEventListener('pointerup', stopPointer); chrome.addEventListener('pointercancel', stopPointer); chrome.addEventListener('lostpointercapture', stopPointer); return chrome; }; const deactivate = () => { stopPointer(); persistState(); releaseComposerTopLayers(); state = null; stateProfile = ''; if (chrome) chrome.hidden = true; clearGeometry(); clearComposerOverflowLayers(); clearComposerRoot(); }; const observeComposer = (element) => { if (composerObserver) composerObserver.disconnect(); if (composer && composer !== element) releaseComposerTopLayers(); composer = element; composerObserver = new MutationObserver(() => { if (!composer || !composer.isConnected || composer.classList.contains('closed') || !composerElementAvailable(composer)) { deactivate(); return; } ensureChrome(); syncComposerRoot(); if (!state || activeStateProfile() !== stateProfile) syncStateProfile(); if (composer.classList.contains('fullscreen')) { stopPointer(); composer.style.removeProperty('translate'); } else if (state && !composer.style.getPropertyValue('translate')) { applyState(state); } scheduleChromeSync(); syncComposerTopLayers(); scheduleComposerOverflowSync(); }); composerObserver.observe(composer, { attributes: true, attributeFilter: ['class', 'hidden', 'aria-hidden'], childList: true, subtree: true, }); }; const open = (element) => { if (!sessionActive() || !element) return false; ensureChrome(); if (composer !== element) observeComposer(element); syncComposerRoot(); if (!state || activeStateProfile() !== stateProfile) syncStateProfile(); else scheduleChromeSync(); syncComposerTopLayers(); scheduleComposerOverflowSync(); return true; }; const syncComposerElement = () => { const nextComposer = document.querySelector('#reply-control'); if (nextComposer === composer) { if (nativeComposerVisible(nextComposer) && !state) open(nextComposer); return; } if (composerObserver) composerObserver.disconnect(); composerObserver = null; if (composer) deactivate(); composer = null; if (!nextComposer) return; observeComposer(nextComposer); if (nativeComposerVisible(nextComposer)) open(nextComposer); }; const composerMutationNodeIsRelevant = (node) => { const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement); return !!(element && (element.matches('#reply-control') || element.querySelector('#reply-control'))); }; const composerHostMutationIsRelevant = (mutation) => ( [...mutation.addedNodes, ...mutation.removedNodes].some(composerMutationNodeIsRelevant) ); const syncLayer = () => { if (!composer || !nativeComposerVisible(composer)) return false; syncComposerRoot(); if (state) syncStateProfile(); syncComposerTopLayers(); scheduleComposerOverflowSync(); return true; }; const onWindowResize = () => { stopPointer(); if (state) syncStateProfile(); else scheduleChromeSync(); syncComposerTopLayers(); scheduleComposerOverflowSync(); }; const destroy = () => { if (destroyed) return; destroyed = true; stopPointer(); persistState(); if (composerObserver) composerObserver.disconnect(); if (composerHostObserver) composerHostObserver.disconnect(); if (chromeFrame) cancelAnimationFrame(chromeFrame); window.removeEventListener('resize', onWindowResize); releaseComposerTopLayers(); if (chrome) removeOwnedNode(chrome); chrome = null; state = null; stateProfile = ''; clearGeometry(); clearComposerOverflowLayers(); clearComposerRoot(); composer = null; }; if (document.body) { composerHostObserver = new MutationObserver((mutations) => { if (mutations.some(composerHostMutationIsRelevant)) syncComposerElement(); }); composerHostObserver.observe(document.body, { childList: true, subtree: true }); } syncComposerElement(); window.addEventListener('resize', onWindowResize); return { open, syncFont, syncLayer, destroy }; } function discourseModelValue(model, key) { if (!model) return undefined; if (typeof model.get === 'function') return model.get(key); return model[key]; } function rememberReaderNativeComposer(ctx, composer, metadata = {}) { const model = discourseModelValue(composer, 'model'); if (!ctx || !composer || !model) return false; ctx.nativeComposerSession = { composer, model, ...metadata }; if (typeof ctx.bindNativeComposerRefresh === 'function') ctx.bindNativeComposerRefresh(); return true; } function cancelNativeComposerDraftDebounce(composer) { const runloopModule = tryRequire('@ember/runloop'); const cancelRunloop = runloopModule && ( runloopModule.cancel || (runloopModule.default && runloopModule.default.cancel) ); if (typeof cancelRunloop === 'function') cancelRunloop(composer && composer._saveDraftDebounce); } async function discardReaderNativeComposer(ctx) { const ownedSession = ctx && ctx.nativeComposerSession; const visibleComposer = nativeComposerVisible(document.querySelector('#reply-control')) ? lookupDiscourse('service:composer') || lookupDiscourse('controller:composer') : null; const visibleModel = discourseModelValue(visibleComposer, 'model'); const composer = visibleModel ? visibleComposer : ownedSession && ownedSession.composer; const model = visibleModel || ownedSession && ownedSession.model; if (ctx) ctx.nativeComposerSession = null; if (!composer || discourseModelValue(composer, 'model') !== model) return true; const appEvents = composer.appEvents || lookupDiscourse('service:app-events'); let cleanupError = null; try { composer.skipAutoSave = true; cancelNativeComposerDraftDebounce(composer); } catch (error) { cleanupError = error; } try { if (typeof composer.destroyDraft === 'function') await composer.destroyDraft(); } catch (error) { cleanupError = error; } if (discourseModelValue(composer, 'model') === model) { try { if (typeof model.clearState === 'function') model.clearState(); } catch (error) { cleanupError = cleanupError || error; } try { if (typeof composer.close === 'function') composer.close(); } catch (error) { cleanupError = cleanupError || error; } try { if (appEvents && typeof appEvents.trigger === 'function') appEvents.trigger('composer:cancelled'); } catch (error) { cleanupError = cleanupError || error; } } try { composer.skipAutoSave = false; } catch (error) { cleanupError = cleanupError || error; } if (cleanupError) console.warn('[LINUX DO reader] native composer discard cleanup failed', cleanupError); return !cleanupError; } async function saveAndCloseReaderNativeComposer(ctx) { const ownedSession = ctx && ctx.nativeComposerSession; if (ctx) ctx.nativeComposerSession = null; const composer = ownedSession && ownedSession.composer; const model = ownedSession && ownedSession.model; if (!composer || discourseModelValue(composer, 'model') !== model) return true; try { cancelNativeComposerDraftDebounce(composer); if (typeof composer.saveAndCloseComposer === 'function') { const closeResult = composer.saveAndCloseComposer(); const savePromise = composer._saveDraftPromise; await Promise.resolve(closeResult); if (savePromise) await savePromise; return true; } composer.skipAutoSave = true; const savePromise = discourseModelValue(model, 'anyDirty') && typeof model.saveDraft === 'function' ? model.saveDraft() : null; if (typeof model.clearState === 'function') model.clearState(); if (typeof composer.close === 'function') composer.close(); const appEvents = composer.appEvents || lookupDiscourse('service:app-events'); if (appEvents && typeof appEvents.trigger === 'function') appEvents.trigger('composer:cancelled'); if (savePromise) await savePromise; return true; } catch (error) { console.warn('[LINUX DO reader] native composer draft save failed', error); return false; } finally { try { composer.skipAutoSave = false; } catch (error) {} } } function nativeComposerFailure(reason, error) { const messages = { target: '无法识别要回复的楼层,请重新展开该楼层后再试', service: 'LINUX DO 原生回复组件尚未就绪,请稍后重试', model: '无法创建原生回复上下文,请刷新页面后重试', draft: '无法获取当前主题草稿信息,请刷新页面后重试', open: 'LINUX DO 原生回复打开失败,请稍后重试', }; console.warn(`[LINUX DO reader] native composer: ${reason}`, error || ''); showSelectionToast(messages[reason] || messages.open); return false; } async function waitForNativeComposerPopup(ctx, timeout = 5000) { const deadline = Date.now() + timeout; let preparedPopup = null; while (Date.now() < deadline) { const popup = document.querySelector('#reply-control'); if (composerElementAvailable(popup) && !popup.classList.contains('closed') && popup !== preparedPopup && ctx && ctx.nativeComposerWindow) { ctx.nativeComposerWindow.open(popup); preparedPopup = popup; } if (nativeComposerVisible(popup)) return true; await waitForDelay(80); } return false; } function focusReaderNativeComposerInput(ctx) { requestAnimationFrame(() => { if (ctx?.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return; const popup = document.querySelector('#reply-control'); if (!nativeComposerVisible(popup)) return; const input = Array.from(popup.querySelectorAll( 'textarea.d-editor-input, textarea, .ProseMirror[contenteditable="true"]' )).find((candidate) => composerElementAvailable(candidate) && !candidate.matches('[disabled],[aria-disabled="true"]')); if (!input) return; try { input.focus({ preventScroll: true }); } catch (error) { input.focus(); } }); } function createDetachedComposerModels(postNode, ctx, Topic, Post) { const topicData = ctx.topicData || {}; const postData = postNode && postNode._ldpPostData || {}; const topicModel = Topic.create({ id: +ctx.topicId, title: topicData.title || '', fancy_title: topicData.fancy_title || topicData.title || '', slug: topicData.slug || 'topic', category_id: topicData.category_id, archetype: topicData.archetype || 'regular', draft_key: topicData.draft_key, draft_sequence: topicData.draft_sequence, posts_count: topicData.posts_count, highest_post_number: topicData.highest_post_number, last_posted_at: topicData.last_posted_at, chunk_size: topicData.chunk_size, details: Object.assign({ can_create_post: true }, topicData.details || {}), tags: Array.isArray(topicData.tags) ? topicData.tags.slice() : [], }); const postModel = Post.create(Object.assign({}, postData, { id: +(postNode && postNode.dataset.postId), topic_id: +ctx.topicId, post_number: +(postNode && postNode.dataset.postNumber), username: postData.username || (postNode && postNode.dataset.username) || '', name: postData.name || '', avatar_template: postData.avatar_template || '', post_type: postData.post_type || 1, reply_count: postData.reply_count || 0, topic: topicModel, })); return { topicModel, postModel }; } async function tryOpenNativeReplyComposer(post, ctx, initialRaw = '') { const postNumber = +(post && post.dataset.postNumber); const postId = +(post && post.dataset.postId); if (!postNumber || !postId) return nativeComposerFailure('target'); const composer = lookupDiscourse('service:composer') || lookupDiscourse('controller:composer'); const appEvents = composer && (composer.appEvents || lookupDiscourse('service:app-events')); const composerModule = tryRequire('discourse/models/composer'); const topicModule = tryRequire('discourse/models/topic'); const postModule = tryRequire('discourse/models/post'); const draftModule = tryRequire('discourse/models/draft'); const Composer = composerModule && (composerModule.default || composerModule); const Topic = topicModule && (topicModule.default || topicModule); const Post = postModule && (postModule.default || postModule); const Draft = draftModule && (draftModule.default || draftModule); if (!composer || typeof composer.open !== 'function' || !appEvents) { return nativeComposerFailure('service'); } if (!Composer || !Composer.REPLY || !Topic || typeof Topic.create !== 'function' || !Post || typeof Post.create !== 'function') { return nativeComposerFailure('model'); } if (!Draft || typeof Draft.get !== 'function') return nativeComposerFailure('draft'); const rememberReplyComposer = () => rememberReaderNativeComposer(ctx, composer, { action: 'reply', parentPostNumber: postNumber, highestPostNumber: Math.max( 0, +(ctx.totalPosts || 0), +(ctx.topicData && ctx.topicData.highest_post_number || 0), ), }); try { let topicData = ctx.topicData || {}; if ((!topicData.draft_key || topicData.draft_sequence == null) && ctx.loader && typeof ctx.loader.refreshTopicData === 'function') { const freshTopic = await ctx.loader.refreshTopicData({ prime: false }); if (freshTopic) { ctx.topicData = freshTopic; topicData = freshTopic; } } if (!topicData.draft_key || topicData.draft_sequence == null) { return nativeComposerFailure('draft'); } if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false; const { topicModel, postModel } = createDetachedComposerModels(post, ctx, Topic, Post); const quoteRaw = String(initialRaw || '').trim(); const currentModel = discourseModelValue(composer, 'model'); const currentTopicId = +discourseModelValue(discourseModelValue(currentModel, 'topic'), 'id'); const currentViewOpen = discourseModelValue(currentModel, 'viewOpen') === true || String(discourseModelValue(currentModel, 'composeState') || '').toLowerCase() === 'open'; const currentAction = discourseModelValue(currentModel, 'action'); const currentReply = String(discourseModelValue(currentModel, 'reply') || ''); const sameTopicReplyOpen = currentModel && currentViewOpen && currentTopicId === +ctx.topicId && currentAction === Composer.REPLY; if (sameTopicReplyOpen && await waitForNativeComposerPopup(ctx, 640)) { rememberReplyComposer(); const replyTarget = postNumber === 1 ? null : postModel; if (typeof currentModel.set === 'function') currentModel.set('post', replyTarget); else currentModel.post = replyTarget; if (quoteRaw) appEvents.trigger('composer:insert-block', quoteRaw); focusReaderNativeComposerInput(ctx); return true; } const currentPopup = document.querySelector('#reply-control'); const currentPopupAvailable = composerElementAvailable(currentPopup) && !currentPopup.classList.contains('closed'); if (currentViewOpen && (currentTopicId === +ctx.topicId || !currentPopupAvailable)) { console.info('[LINUX DO reader] native composer: recovering stale view', { topicId: +ctx.topicId, previousTopicId: currentTopicId || 0, action: currentAction || '', popupVisible: nativeComposerVisible(currentPopup), }); if (ctx.nativeComposerSession && ctx.nativeComposerSession.model === currentModel) { ctx.nativeComposerSession = null; } if (typeof composer.close === 'function') composer.close(); await waitForDelay(0); } const options = { action: Composer.REPLY, draftKey: topicData.draft_key, draftSequence: topicData.draft_sequence, skipJumpOnSave: true, }; if (postNumber === 1) options.topic = topicModel; else options.post = postModel; const draftData = await Draft.get(options.draftKey); if (draftData && draftData.draft) { const data = JSON.parse(draftData.draft); options.draftSequence = draftData.draft_sequence; options.reply = `${String(data.reply || '')}${quoteRaw ? `\n${quoteRaw}` : ''}`; if (data.whisper !== undefined) options.whisper = data.whisper; } else if (quoteRaw) { options.quote = quoteRaw; } if (currentReply && currentViewOpen && currentTopicId === +ctx.topicId) { options.reply = `${currentReply}${quoteRaw ? `\n${quoteRaw}` : ''}`; delete options.quote; } console.info('[LINUX DO reader] native composer: opening', { topicId: +ctx.topicId, postId, postNumber, quoted: !!quoteRaw, }); await composer.open(options); rememberReplyComposer(); if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open'); focusReaderNativeComposerInput(ctx); console.info('[LINUX DO reader] native composer: visible', { topicId: +ctx.topicId, postId, postNumber, }); return true; } catch (error) { if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false; return nativeComposerFailure('open', error); } } async function tryOpenNativeEditComposer(post, ctx) { const postNumber = +(post && post.dataset.postNumber); const postId = +(post && post.dataset.postId); const postData = post && post._ldpPostData || {}; if (!postNumber || !postId || postData.can_edit !== true) return false; const composer = lookupDiscourse('service:composer') || lookupDiscourse('controller:composer'); const composerModule = tryRequire('discourse/models/composer'); const topicModule = tryRequire('discourse/models/topic'); const postModule = tryRequire('discourse/models/post'); const Composer = composerModule && (composerModule.default || composerModule); const Topic = topicModule && (topicModule.default || topicModule); const Post = postModule && (postModule.default || postModule); if (!composer || typeof composer.open !== 'function' || !Composer || !Composer.EDIT || !Topic || typeof Topic.create !== 'function' || !Post || typeof Post.create !== 'function') { return nativeComposerFailure('service'); } try { const freshPost = await fetchJSON(`${BASE}/posts/${postId}.json`, { cacheMode: 'refresh', allowStaleOnError: false, }); post._ldpPostData = Object.assign({}, postData, freshPost); const { postModel } = createDetachedComposerModels(post, ctx, Topic, Post); const topicData = ctx.topicData || {}; await composer.open({ action: Composer.EDIT, post: postModel, draftKey: topicData.draft_key, draftSequence: topicData.draft_sequence, }); rememberReaderNativeComposer(ctx, composer, { action: 'edit', postNumber, postId, }); if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open'); return true; } catch (error) { return nativeComposerFailure('open', error); } } function detachedNativePostModel(post, ctx) { const topicModule = tryRequire('discourse/models/topic'); const postModule = tryRequire('discourse/models/post'); const Topic = topicModule && (topicModule.default || topicModule); const Post = postModule && (postModule.default || postModule); if (!Topic || typeof Topic.create !== 'function' || !Post || typeof Post.create !== 'function') return null; return createDetachedComposerModels(post, ctx, Topic, Post).postModel; } function openNativeAdminPostMenu(post, anchor, ctx) { const menu = lookupDiscourse('service:menu'); const componentModule = tryRequire('discourse/components/admin-post-menu'); const AdminPostMenu = componentModule && (componentModule.default || componentModule); const topicController = lookupDiscourse('controller:topic'); const postModel = detachedNativePostModel(post, ctx); if (!menu || typeof menu.show !== 'function' || !AdminPostMenu || !topicController || typeof topicController.send !== 'function' || !postModel) { showSelectionToast('LINUX DO 原生楼层管理菜单尚未就绪,请稍后重试'); return false; } const topicAction = (name) => () => topicController.send(name, postModel); menu.show(anchor, { identifier: 'admin-post-menu', component: AdminPostMenu, modalForMobile: true, autofocus: true, data: { post: postModel, changeNotice: topicAction('changeNotice'), changePostOwner: topicAction('changePostOwner'), grantBadge: topicAction('grantBadge'), lockPost: topicAction('lockPost'), permanentlyDeletePost: topicAction('permanentlyDeletePost'), rebakePost: topicAction('rebakePost'), showPagePublish: topicAction('showPagePublish'), togglePostType: topicAction('togglePostType'), toggleWiki: topicAction('toggleWiki'), unhidePost: topicAction('unhidePost'), unlockPost: topicAction('unlockPost'), scheduleRerender: () => ctx.refreshPostByNumber && ctx.refreshPostByNumber(+post.dataset.postNumber), }, }); return true; } function removeReaderPost(post, ctx) { const postId = +(post && post.dataset.postId); const postNumber = +(post && post.dataset.postNumber); const postData = post && post._ldpPostData || {}; const parentNumber = +(postData.reply_to_post_number || post && post.dataset.replyToPostNumber || 0); readerPostCopies(postId, ctx).forEach((node) => removeOwnedNode(node)); const item = ctx.streamItemMap.get(postNumber); if (item) { const index = item.index; cancelPostContentHydration(item.node, ctx); cancelPostLatexRender(item.node, ctx); destroyReaderMedia(item.node); ctx.streamHydratedNodes.delete(item.node); ctx.streamItems.splice(index, 1); ctx.streamItemMap.delete(postNumber); ctx.streamNodeMap.delete(postNumber); ctx.streamMountedNodes.delete(item.node); reindexStreamItems(ctx, Math.max(0, index)); invalidateStreamLayout(ctx); scheduleStreamWindowSync(ctx); } const replies = parentNumber && ctx.directRepliesByParent.get(parentNumber); if (replies) replies.delete(postNumber); const state = parentNumber && ctx.subReplyState.get(parentNumber); const parentNode = parentNumber && ctx.streamNodeMap.get(parentNumber); if (state) { state.all = state.all.filter((reply) => +reply.post_number !== postNumber); if (state.nodeCache) state.nodeCache.delete(postNumber); state.totalCount = Math.max(0, state.totalCount - 1); state.renderedKey = ''; if (parentNode && parentNode.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode); } if (parentNode) { const total = Math.max(0, +(parentNode.dataset.childReplyTotal || 0) - 1); parentNode.dataset.childReplyTotal = String(total); if (parentNode._ldpPostData) parentNode._ldpPostData.reply_count = total; syncChildControls(parentNode); } const cacheEntry = TOPIC_DATA_CACHE.get(String(ctx.topicId)); const cachedTopic = (cacheEntry && cacheEntry.topic) || ctx.topicData; if (cachedTopic && cachedTopic.post_stream && Array.isArray(cachedTopic.post_stream.posts)) { cachedTopic.post_stream.posts = cachedTopic.post_stream.posts.filter((item) => +item.id !== postId); setCachedTopicData(ctx.topicId, cachedTopic); } updateCommentsHeader(ctx); ctx.timeline.update(); } async function deleteReaderPost(post, ctx) { const postData = post && post._ldpPostData || {}; const postId = +(post && post.dataset.postId); if (!postId || postData.can_delete !== true) return false; return runReaderActionOnce(ctx, `post:${postId}:delete`, async () => { if (!window.confirm('确定删除这条回复吗?')) return false; await apiSend(`${BASE}/posts/${postId}`, 'DELETE', { context: location.pathname }); removeReaderPost(post, ctx); return true; }); } function getNativeCreateBoost() { const module = tryRequire('discourse/plugins/discourse-boosts/discourse/lib/create-boost'); return module && (module.default || module); } function positionNativeBoostMenu(anchor, menu) { const anchorRect = anchor.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); const width = Math.max(menuRect.width || 0, 280); const height = Math.max(menuRect.height || 0, 84); const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - width - 8)); const below = anchorRect.bottom + 6; const top = below + height <= window.innerHeight - 8 ? below : Math.max(8, anchorRect.top - height - 6); menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; return true; } function syncRenderedBoost(post, postData, ctx) { const current = post.querySelector(':scope > .ldp-boost-list'); const holder = document.createElement('template'); holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx }); const next = holder.content.firstElementChild; if (current && next) current.replaceWith(next); else if (next) post.insertBefore(next, post.querySelector(':scope > .ldp-collapse-replies')); hydratePersistentAvatarImages(post); const actions = post.querySelector(':scope > .ldp-reactions .ldp-actions'); const boostButton = actions && actions.querySelector('.ldp-boostbtn'); if (canShowBoostButton(postData) && actions && !boostButton) { const actionHolder = document.createElement('template'); actionHolder.innerHTML = `<button class="ldp-btn ldp-boostbtn" type="button" aria-label="boost">${icon('rocket')}</button>`; const contextSlot = actions.querySelector('.ldp-context-actions-slot'); actions.insertBefore(actionHolder.content.firstElementChild, contextSlot || null); } else if (!canShowBoostButton(postData)) { boostButton?.remove(); } } function syncDeletedReaderBoost(post, boostId, ctx) { const postId = +(post && post.dataset.postId || 0); if (!postId || !boostId) return; let latestPostData = null; const copies = readerPostCopies(postId, ctx); (copies.length ? copies : [post]).forEach((copy) => { if (!copy) return; const postData = copy._ldpPostData || {}; const boosts = Array.isArray(postData.boosts) ? postData.boosts : (postData.boosts ? [postData.boosts] : []); postData.boosts = boosts.filter((boost) => String(boost && boost.id || '') !== String(boostId)); postData.can_boost = true; copy._ldpPostData = postData; latestPostData = postData; syncRenderedBoost(copy, postData, ctx); }); if (latestPostData) persistReaderPostState(ctx, latestPostData); } async function deleteReaderBoost(post, boostBubble, ctx) { const boostId = boostBubble && boostBubble.dataset.boostId; if (!post || !boostId) return false; await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}`, 'DELETE', null, null, { invalidateTags: [`post:${post.dataset.postId}`, `topic:${ctx.topicId}`], }); syncDeletedReaderBoost(post, boostId, ctx); showSelectionToast('Boost 已删除'); return true; } async function openReaderBoostReportDialog(boostBubble, ctx) { const boostId = boostBubble && boostBubble.dataset.boostId; if (!boostId) return false; const boost = await fetchJSON( `${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}.json`, { cacheMode: 'bypass', allowStaleOnError: false, key: `boost:${boostId}:permissions` } ); if (!boost || boost.can_flag !== true) { throw new Error(boost && boost.user_flag_status ? '你已经举报过这个 Boost' : '当前账号不能举报这个 Boost'); } const availableFlags = new Set(Array.isArray(boost.available_flags) ? boost.available_flags : []); const site = lookupDiscourse('service:site'); const siteFlags = Array.from(site && site.flagTypes || []); const descriptionText = (value) => { const holder = document.createElement('template'); holder.innerHTML = String(value || ''); return String(holder.content.textContent || '').replace(/\s+/g, ' ').trim(); }; const reportOptions = siteFlags .filter((flag) => ( flag && flag.enabled !== false && availableFlags.has(flag.name_key) && (!Array.isArray(flag.applies_to) || flag.applies_to.includes('DiscourseBoosts::Boost')) )) .map((flag) => ({ id: +flag.id, nameKey: flag.name_key, label: flag.name || flag.name_key, description: descriptionText(flag.description || flag.short_description) || '提交给社区管理人员审核。', requireMessage: flag.require_message === true, })); if (!reportOptions.length) { READER_REPORT_OPTIONS.forEach((option) => { if (availableFlags.has(option.nameKey)) reportOptions.push(option); }); } if (!reportOptions.length) throw new Error('当前没有可用的 Boost 举报类型'); const username = String(boost.user && boost.user.username || boostBubble.dataset.boostUser || '').trim(); return !!openReaderReportForm(ctx, { title: '举报 Boost', intro: username ? `举报 @${username} 的 Boost 会直接提交给社区,不会离开阅读器。` : '举报会直接提交给社区,不会离开阅读器。', options: reportOptions, placeholder: '选填;所选类型要求说明时必须填写', onSubmit: async (actionType, message) => { const params = { flag_type_id: actionType }; if (message) params.message = message; await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}/flags`, 'POST', params); return '举报已提交'; }, }); } function renderNativeBoostMenu(post, anchor, ctx, initialRaw = '') { const emojiMenuIdentifier = 'ldp-native-boost-emoji-picker'; const overlay = post && post.closest('.ldp-overlay'); const postData = post && post._ldpPostData; const scrollRoot = ctx && ctx.scrollRoot; const createBoost = getNativeCreateBoost(); const currentUser = ME_CURRENT_USER; const nativeMenu = lookupDiscourse('service:menu'); const prettyTextEmoji = tryRequire('pretty-text/emoji'); const discourseText = tryRequire('discourse/lib/text'); const unicodeEmojiRegex = prettyTextEmoji && prettyTextEmoji.emojiReplacementRegex ? new RegExp(prettyTextEmoji.emojiReplacementRegex, 'g') : null; if (!overlay || !postData || !document.body) return false; const previous = document.body.querySelector(':scope > .ldp-native-boost-menu'); if (previous && typeof previous._ldpClose === 'function') previous._ldpClose(); else if (previous) previous.remove(); const menu = document.createElement('div'); menu.className = 'ldp-native-boost-menu sciapp-ldp-owned'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', '助推'); menu.innerHTML = ` <div class="discourse-boosts__input-container"> <div class="discourse-boosts__input" contenteditable="true" role="textbox" aria-label="Boost 内容" data-placeholder="写一句,最多 16 字"></div> <span class="ldp-native-boost-count">0/16</span> <button class="btn-transparent btn-icon-only discourse-boosts__emoji-btn" type="button" aria-label="插入表情">${icon('smile')}</button> <button class="btn-default --success btn-icon-only discourse-boosts__submit" type="button" aria-label="发送" disabled>${icon('check')}</button> <button class="btn-default --danger btn-icon-only discourse-boosts__cancel" type="button" aria-label="取消">${icon('x')}</button> </div> <span class="ldp-native-boost-error" role="status"></span>`; document.body.appendChild(menu); applyReaderThemeVariables(menu); positionNativeBoostMenu(anchor, menu); bindFloatingSurfaceWheel(menu); const editor = menu.querySelector('.discourse-boosts__input'); const count = menu.querySelector('.ldp-native-boost-count'); const emojiButton = menu.querySelector('.discourse-boosts__emoji-btn'); const submit = menu.querySelector('.discourse-boosts__submit'); const cancel = menu.querySelector('.discourse-boosts__cancel'); const error = menu.querySelector('.ldp-native-boost-error'); let submitting = false; let closed = false; let anchorSyncFrame = 0; let anchorObserver = null; let outsideListenerTimer = 0; let previousEditorHTML = ''; let emojiPickerContent = null; const closeEmojiPicker = () => { if (emojiPickerContent && typeof emojiPickerContent._ldpStopEmojiImageCache === 'function') { emojiPickerContent._ldpStopEmojiImageCache(); } emojiPickerContent = null; if (!nativeMenu || typeof nativeMenu.close !== 'function') return; try { const result = nativeMenu.close(emojiMenuIdentifier); if (result && typeof result.catch === 'function') result.catch(() => {}); } catch (e) {} }; const placeEditorCursorAtEnd = () => { editor.focus(); const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); const selection = readerSelection(); if (!selection) return; selection.removeAllRanges(); selection.addRange(range); }; const textStats = (value) => { const text = String(value || '').replace(/\u00a0/g, ' '); const unicodeMatches = unicodeEmojiRegex ? text.match(unicodeEmojiRegex) : null; return { raw: text, length: Array.from(unicodeMatches ? text.replace(unicodeEmojiRegex, 'x') : text).length, emojiCount: unicodeMatches ? unicodeMatches.length : 0, }; }; const readInput = () => { const stats = { raw: '', length: 0, emojiCount: 0 }; const visit = (node) => { if (node.nodeType === Node.TEXT_NODE) { const current = textStats(node.textContent); stats.raw += current.raw; stats.length += current.length; stats.emojiCount += current.emojiCount; return; } if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.nodeName === 'IMG' && node.classList.contains('emoji')) { stats.raw += node.getAttribute('alt') || ''; stats.length += 1; stats.emojiCount += 1; return; } node.childNodes.forEach(visit); }; editor.childNodes.forEach(visit); return stats; }; const rectsOverlap = (rect, clip) => rect.bottom > clip.top && rect.top < clip.bottom && rect.right > clip.left && rect.left < clip.right; const anchorIsVisible = () => { if (!anchor.isConnected || !menu.isConnected) return false; const rect = anchor.getBoundingClientRect(); if (!rect.width || !rect.height) return false; const viewport = { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 }; if (!rectsOverlap(rect, viewport)) return false; return !scrollRoot || rectsOverlap(rect, scrollRoot.getBoundingClientRect()); }; const close = () => { if (closed) return; closed = true; closeEmojiPicker(); clearTimeout(outsideListenerTimer); document.removeEventListener('pointerdown', onOutside, true); if (scrollRoot) scrollRoot.removeEventListener('scroll', scheduleAnchorSync); window.removeEventListener('resize', scheduleAnchorSync); if (anchorSyncFrame) cancelAnimationFrame(anchorSyncFrame); if (anchorObserver) anchorObserver.disconnect(); menu.remove(); }; const onOutside = (event) => { if (!menu.isConnected) return close(); if (eventPathClosest(event, `[data-identifier="${emojiMenuIdentifier}"],.emoji-picker`)) return; if (!eventPathIncludes(event, menu) && !eventPathIncludes(event, anchor)) close(); }; const syncAnchor = () => { anchorSyncFrame = 0; if (!anchorIsVisible()) return close(); positionNativeBoostMenu(anchor, menu); }; const scheduleAnchorSync = () => { if (closed || anchorSyncFrame) return; anchorSyncFrame = requestAnimationFrame(syncAnchor); }; const syncInput = () => { let stats = readInput(); if (stats.length > 16 || stats.emojiCount > 5) { editor.innerHTML = previousEditorHTML; placeEditorCursorAtEnd(); stats = readInput(); } else { if (stats.length === 0 && editor.innerHTML) editor.innerHTML = ''; previousEditorHTML = editor.innerHTML; } count.textContent = `${stats.length}/16`; emojiButton.disabled = submitting || stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5; submit.disabled = submitting || !stats.raw.trim(); return stats.raw; }; const insertEmoji = (code) => { const emojiCode = String(code || '').replace(/^:|:$/g, ''); const stats = readInput(); if (!emojiCode || stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5) return; const buildEmojiUrl = prettyTextEmoji && prettyTextEmoji.buildEmojiUrl; const isCustomEmoji = prettyTextEmoji && prettyTextEmoji.isCustomEmoji; const emojiOptions = discourseText && discourseText.emojiOptions; if (typeof buildEmojiUrl !== 'function' || typeof emojiOptions !== 'function') { error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试'; return; } try { const options = emojiOptions(); const image = document.createElement('img'); image.className = typeof isCustomEmoji === 'function' && isCustomEmoji(emojiCode, options) ? 'emoji emoji-custom' : 'emoji'; image.alt = `:${emojiCode}:`; image.src = buildEmojiUrl(emojiCode, options); if (stats.length) editor.appendChild(document.createTextNode(' ')); else editor.innerHTML = ''; editor.appendChild(image); error.textContent = ''; placeEditorCursorAtEnd(); syncInput(); } catch (e) { error.textContent = '表情插入失败,请稍后重试'; } }; const positionEmojiPickerInReader = (content) => { const reader = post.closest('.ldp-modal'); if (!content || !reader) return; if (emojiPickerContent !== content) { if (emojiPickerContent && typeof emojiPickerContent._ldpStopEmojiImageCache === 'function') { emojiPickerContent._ldpStopEmojiImageCache(); } emojiPickerContent = content; watchPersistentEmojiImages(content); } const ownsPopover = !content.hasAttribute('popover'); try { if (ownsPopover) { content.setAttribute('popover', 'manual'); content.dataset.ldpReaderTopLayer = 'portal'; } if (!content.matches(':popover-open')) content.showPopover(); } catch (error) { if (ownsPopover) { content.removeAttribute('popover'); delete content.dataset.ldpReaderTopLayer; } } const padding = 8; const gap = 8; const readerRect = reader.getBoundingClientRect(); const anchorRect = menu.getBoundingClientRect(); const bounds = { top: Math.max(padding, readerRect.top + padding), right: Math.min(window.innerWidth - padding, readerRect.right - padding), bottom: Math.min(window.innerHeight - padding, readerRect.bottom - padding), left: Math.max(padding, readerRect.left + padding), }; const picker = content.querySelector('.emoji-picker'); const initialRect = content.getBoundingClientRect(); const pickerRect = picker && picker.getBoundingClientRect(); const naturalHeight = picker && +(picker.dataset.ldpBoostNaturalHeight || picker.offsetHeight || 0); if (picker && naturalHeight && !picker.dataset.ldpBoostNaturalHeight) { picker.dataset.ldpBoostNaturalHeight = String(naturalHeight); } const scale = picker && picker.offsetHeight ? pickerRect.height / picker.offsetHeight : 1; const chromeHeight = pickerRect ? Math.max(0, initialRect.height - pickerRect.height) : 0; const naturalVisualHeight = naturalHeight ? naturalHeight * scale + chromeHeight : initialRect.height; const above = Math.max(0, anchorRect.top - gap - bounds.top); const below = Math.max(0, bounds.bottom - anchorRect.bottom - gap); const placeBelow = below >= naturalVisualHeight || (above < naturalVisualHeight && below >= above); const availableHeight = placeBelow ? below : above; if (picker && naturalHeight && scale > 0) { const targetVisualHeight = Math.min( naturalHeight * scale, Math.max(120 * scale, availableHeight - chromeHeight), ); picker.style.height = `${Math.round(targetVisualHeight / scale)}px`; const pickerContent = picker.querySelector('.emoji-picker__content'); if (pickerContent) { pickerContent.style.height = 'auto'; pickerContent.style.minHeight = '0'; pickerContent.style.flex = '1 1 auto'; } const sectionsNav = picker.querySelector('.emoji-picker__sections-nav'); if (sectionsNav) sectionsNav.style.height = '100%'; } Object.assign(content.style, { position: 'fixed', inset: 'auto', right: 'auto', bottom: 'auto', margin: '0', transform: 'none', }); const panelRect = content.getBoundingClientRect(); let left = anchorRect.left; if (left + panelRect.width > bounds.right) left = anchorRect.right - panelRect.width; left = Math.max(bounds.left, Math.min(left, Math.max(bounds.left, bounds.right - panelRect.width))); let top = placeBelow ? anchorRect.bottom + gap : anchorRect.top - panelRect.height - gap; top = Math.max(bounds.top, Math.min(top, Math.max(bounds.top, bounds.bottom - panelRect.height))); content.style.left = `${Math.round(left)}px`; content.style.top = `${Math.round(top)}px`; const placedRect = content.getBoundingClientRect(); content.style.left = `${Math.round(left + left - placedRect.left)}px`; content.style.top = `${Math.round(top + top - placedRect.top)}px`; }; const openEmojiPicker = () => { const pickerModule = tryRequire('discourse/components/emoji-picker/detached'); const EmojiPickerDetached = pickerModule && (pickerModule.default || pickerModule); if (!nativeMenu || typeof nativeMenu.show !== 'function' || !EmojiPickerDetached) { error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试'; return; } error.textContent = ''; try { const result = nativeMenu.show(emojiButton, { identifier: emojiMenuIdentifier, component: EmojiPickerDetached, modalForMobile: false, strategy: 'fixed', fallbackPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'], computePosition: positionEmojiPickerInReader, data: { term: '', context: 'boost', didSelectEmoji: insertEmoji, }, }); if (result && typeof result.catch === 'function') { result.catch(() => { if (!closed) error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试'; }); } } catch (e) { error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试'; } }; const send = async () => { const raw = syncInput().trim(); if (!raw || submitting) return; if (typeof createBoost !== 'function' || !currentUser) { error.textContent = 'LinuxDo Boost 组件尚未就绪,请稍后重试'; return; } submitting = true; submit.disabled = true; emojiButton.disabled = true; cancel.disabled = true; editor.contentEditable = 'false'; error.textContent = ''; try { await createBoost(postData, raw, currentUser); syncRenderedBoost(post, postData, ctx); persistReaderPostState(ctx, postData); await clearStoredResponseCache({ tags: [`post:${postData.id}`, `topic:${ctx.topicId}`] }); close(); } catch (e) { submitting = false; cancel.disabled = false; editor.contentEditable = 'true'; error.textContent = `发送失败:${e && e.message ? e.message : '请稍后重试'}`; syncInput(); editor.focus(); } }; menu._ldpCloseEmojiPicker = closeEmojiPicker; menu._ldpClose = close; editor.addEventListener('input', syncInput); editor.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); } else if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send(); } }); emojiButton.addEventListener('click', openEmojiPicker); submit.addEventListener('click', send); cancel.addEventListener('click', close); if (scrollRoot) scrollRoot.addEventListener('scroll', scheduleAnchorSync, { passive: true }); window.addEventListener('resize', scheduleAnchorSync, { passive: true }); if (typeof IntersectionObserver === 'function') { anchorObserver = new IntersectionObserver((entries) => { const entry = entries[0]; if (!entry || !entry.isIntersecting) close(); else scheduleAnchorSync(); }, { root: scrollRoot || null, threshold: 0 }); anchorObserver.observe(anchor); } outsideListenerTimer = setTimeout(() => { outsideListenerTimer = 0; if (!closed) document.addEventListener('pointerdown', onOutside, true); }, 0); editor.textContent = Array.from(String(initialRaw || '')).slice(0, 16).join(''); syncInput(); editor.focus(); if (initialRaw) { placeEditorCursorAtEnd(); } return true; } /* ============ 9. 楼中楼分批渲染 ============ */ function renderSubReplyBatch(postNumber, ctx, pageDelta = 0, preferredParentNode = null) { const state = ctx.subReplyState.get(postNumber); const parentNode = preferredParentNode && preferredParentNode.isConnected && +preferredParentNode.dataset.postNumber === +postNumber ? preferredParentNode : findReplyListParent(postNumber, ctx); if (!state || !parentNode) return; if (parentNode.classList.contains('ldp-nested-preview')) return; const children = parentNode.querySelector(':scope > .ldp-children'); if (!children) return; const pageSize = SUB_REPLY_PAGE_SIZE; const totalCount = Math.max(state.all.length, +(state.totalCount || 0)); const pageCount = Math.max(1, Math.ceil(totalCount / pageSize)); state.pageIndex = Math.max(0, Math.min(pageCount - 1, +(state.pageIndex || 0) + pageDelta)); if (!state.nodeCache) state.nodeCache = new Map(); const start = state.pageIndex * pageSize; const batch = state.all.slice(start, start + pageSize); const renderKey = `${state.pageIndex}:${state.all.length}:${totalCount}:${state.loadingMore ? 1 : 0}:${batch.map((reply) => +(reply.post_number || 0)).join(',')}`; const mountedReplies = Array.from(children.children).filter((node) => node.classList.contains('ldp-post')); if (state.renderedParent === parentNode && state.renderedKey === renderKey && mountedReplies.length === batch.length && mountedReplies.every((node, index) => +node.dataset.postNumber === +(batch[index] && batch[index].post_number || 0))) { return; } parentNode.classList.remove('ldp-nested-branches-ready'); clearCombinedParentPreviewLine(parentNode); const mountedByPostNumber = new Map(mountedReplies.map((node) => [+node.dataset.postNumber, node])); const desiredNodes = []; batch.forEach((rp) => { if (!rp.reply_to_post_number) rp.reply_to_post_number = postNumber; const replyPostNumber = +rp.post_number; let node = mountedByPostNumber.get(replyPostNumber) || state.nodeCache.get(replyPostNumber); if (!node) { node = attachPost(rp, ctx, { nestedList: true, preferredParentNode: parentNode, deferNestedSync: true, }); if (node) { state.nodeCache.set(replyPostNumber, node); } } if (node) { state.nodeCache.set(replyPostNumber, node); desiredNodes.push(node); } }); const actionEl = parentNode.querySelector(':scope > .ldp-children > .ldp-sub-actions'); const desiredSet = new Set(desiredNodes); mountedReplies.forEach((node) => { if (desiredSet.has(node)) return; state.nodeCache.set(+node.dataset.postNumber, node); removeOwnedNode(node, children); }); let before = actionEl || null; for (let index = desiredNodes.length - 1; index >= 0; index--) { const node = desiredNodes[index]; if (node.nextSibling !== before) children.insertBefore(node, before); hydratePersistentAvatarImages(node); before = node; } parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), state.all.length)); const prevBtn = actionEl && actionEl.querySelector('.ldp-sub-page-prev'); const nextBtn = actionEl && actionEl.querySelector('.ldp-sub-page-next'); const infoEl = actionEl && actionEl.querySelector('.ldp-sub-page-info'); if (actionEl && actionEl.parentNode === children && actionEl.nextSibling) children.appendChild(actionEl); if (pageCount > 1) { if (actionEl) actionEl.style.display = 'flex'; if (prevBtn) prevBtn.disabled = !!state.loadingMore || state.pageIndex <= 0; if (nextBtn) nextBtn.disabled = !!state.loadingMore || state.pageIndex >= pageCount - 1; if (infoEl) infoEl.textContent = `${state.pageIndex + 1}/${pageCount}`; } else if (actionEl) { actionEl.style.display = 'none'; } state.renderedParent = parentNode; state.renderedKey = renderKey; scheduleNestedBranchSync(parentNode, ctx); } function ensureLiveReplyShell(parentNode, totalCount, ctx) { if (!parentNode) return null; const total = Math.max(1, +totalCount || 0); parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), total)); if (parentNode._ldpPostData) { parentNode._ldpPostData.reply_count = Math.max(+(parentNode._ldpPostData.reply_count || 0), total); } let children = parentNode.querySelector(':scope > .ldp-children'); const created = !children; if (!children) { const holder = document.createElement('template'); holder.innerHTML = childReplyShellHtml(total); const [collapse, nextChildren, loading] = holder.content.children; const footer = parentNode.querySelector(':scope > .ldp-topic-footer-actions'); [collapse, nextChildren, loading].forEach((node) => parentNode.insertBefore(node, footer || null)); children = nextChildren; } setNestedReplyCollapsed(parentNode, parentNode._ldpPostData?.hidden === true); if (created) { const isTopicStarter = +parentNode.dataset.postNumber === 1; setChildRepliesCollapsed(parentNode, isTopicStarter || PREFS.expandNestedRepliesByDefault !== true); } if (ctx && ctx.repliesIO) ctx.repliesIO.observe(parentNode); syncChildControls(parentNode); return children; } function syncLiveNestedReply(post, ctx, options = {}) { const postNumber = +(post && post.post_number || 0); const parentNumber = +(post && post.reply_to_post_number || 0); const allowOpParent = options.allowOpParent === true; if (!ctx || !postNumber || parentNumber < 1 || (parentNumber === 1 && !allowOpParent)) return false; const previous = ctx.subReplyState.get(parentNumber); const previousAll = previous && Array.isArray(previous.all) ? previous.all : []; const wasAlreadyKnown = previousAll.some((reply) => +(reply && reply.post_number || 0) === postNumber); rememberPostData(ctx, post, parentNumber); const parentNode = ctx.streamNodeMap.get(parentNumber); if (!parentNode) return false; const knownMap = ctx.directRepliesByParent.get(parentNumber); const knownReplies = knownMap ? Array.from(knownMap.values()) : []; const merged = mergeDirectReplies(previousAll, knownReplies, [post]); const previousTotal = Math.max(previousAll.length, +(previous && previous.totalCount || 0)); const pageSize = SUB_REPLY_PAGE_SIZE; const previousPageCount = Math.max(1, Math.ceil(previousTotal / pageSize)); const wasOnLastPage = !previous || +(previous.pageIndex || 0) >= previousPageCount - 1; const totalCount = Math.max( merged.length, previousTotal + (options.incrementTotal === true && !wasAlreadyKnown ? 1 : 0), +(parentNode.dataset.childReplyTotal || 0), +(parentNode._ldpPostData && parentNode._ldpPostData.reply_count || 0), ); ensureLiveReplyShell(parentNode, totalCount, ctx); const pageCount = Math.max(1, Math.ceil(totalCount / pageSize)); const liveReplyIndex = merged.findIndex((reply) => +(reply && reply.post_number || 0) === postNumber); const liveReplyPage = liveReplyIndex >= 0 ? Math.floor(liveReplyIndex / pageSize) : pageCount - 1; const state = { all: merged, pageIndex: wasOnLastPage ? liveReplyPage : +(previous && previous.pageIndex || 0), nodeCache: previous && previous.nodeCache, renderedKey: '', renderedParent: previous && previous.renderedParent, totalCount, fetchedReplyCount: Math.max(merged.length, +(previous && previous.fetchedReplyCount || 0)), fetchAfter: Math.max(postNumber, +(previous && previous.fetchAfter || 0)), hasMore: totalCount > merged.length || !!(previous && previous.hasMore), loadingMore: false, }; ctx.subReplyState.set(parentNumber, state); if (parentNode.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode); return true; } function updateLivePostData(post, ctx, options = {}) { const postNumber = +(post && post.post_number || 0); const streamNode = ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(postNumber); const postId = +(post && post.id || streamNode && streamNode.dataset.postId || 0); const copies = new Set(postId ? readerPostCopies(postId, ctx) : []); if (streamNode) copies.add(streamNode); if (!copies.size) return false; copies.forEach((node) => { node._ldpPostData = Object.assign({}, node._ldpPostData || {}, post); syncPostReadState(node, readerPostIsRead(node._ldpPostData, ctx)); const content = node.querySelector(':scope > .ldp-content'); if (options.refreshContent === true && content && post.cooked != null) { setPostCookedContent(content, post.cooked, node._ldpPostData.link_counts); content.classList.remove('ldp-content-pending'); content.removeAttribute('aria-busy'); bindPostMediaLayoutSync(node, ctx); } if (Array.isArray(post.polls)) renderPostPolls(node, ctx); renderPostSpecialContent(node, ctx); syncPostActionControls(node, ctx); }); const mergedPost = streamNode && streamNode._ldpPostData || Object.assign({}, post); rememberPostData(ctx, mergedPost); const replyCount = +(post.reply_count || 0); if (replyCount > 0 && streamNode) ensureLiveReplyShell(streamNode, replyCount, ctx); return true; } async function focusLiveNestedReply(post, ctx, options = {}) { const postNumber = +(post && post.post_number || 0); const parentNumber = +(post && post.reply_to_post_number || 0); const forceNested = options.forceNested === true; if (!ctx || !postNumber) return false; if (!forceNested && parentNumber <= 1) { return !!ctx.timeline && ctx.timeline.jumpTo(postNumber, { acceptsWork: options.acceptsWork, }); } if (parentNumber < 1) return false; const navigationSequence = options.navigationSequence || beginReaderNavigation(ctx); const jumpStillCurrent = () => readerNavigationIsCurrent(ctx, navigationSequence) && (typeof options.acceptsWork !== 'function' || options.acceptsWork()); if (!jumpStillCurrent()) return false; if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition(); if (!ctx.streamNodeMap.has(parentNumber) && ctx.loadPost) { await ctx.loadPost(parentNumber, { mount: true, ensureAround: true, scope: 'around' }); } if (!jumpStillCurrent()) return false; const parentNode = getStreamPostNode(ctx, parentNumber, { mount: true }) || ctx.streamNodeMap.get(parentNumber); if (!parentNode) return false; syncLiveNestedReply(post, ctx, { allowOpParent: forceNested }); parentNode.classList.remove('ldp-children-collapsed'); setNestedReplyCollapsed(parentNode, false); const state = ctx.subReplyState.get(parentNumber); if (!state) return false; const pageSize = SUB_REPLY_PAGE_SIZE; const replyIndex = state.all.findIndex((reply) => +(reply && reply.post_number || 0) === postNumber); state.pageIndex = replyIndex >= 0 ? Math.floor(replyIndex / pageSize) : Math.max(0, Math.ceil(state.all.length / pageSize) - 1); state.renderedKey = ''; renderSubReplyBatch(parentNumber, ctx, 0, parentNode); syncChildControls(parentNode); const target = parentNode.querySelector( `:scope > .ldp-children > .ldp-post[data-post-number="${postNumber}"]` ); if (!target) return false; syncPostActionControls(target, ctx); if (!scrollToPost(parentNumber, ctx, { ...PINNED_STREAM_SCROLL_OPTIONS, visualNode: target, highlightTarget: target, alignCenter: true, })) return false; await waitForReaderJumpLayout(); if (!jumpStillCurrent() || !target.isConnected) return false; if (!readerTargetNodeIsVisible(target, ctx)) return false; if (ctx.timeline) ctx.timeline.sync(); return true; } async function returnToReaderQuoteSource(source, ctx) { const topicId = +(source && source.topicId || 0); const postNumber = +(source && source.postNumber || 0); const parentPostNumber = +(source && source.parentPostNumber || 0); const nested = source && source.nested === true && parentPostNumber > 0; if (!ctx || !topicId || !postNumber) return false; if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false; if (topicId !== +ctx.topicId) { beginReaderNavigation(ctx); if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition(); await openModal(topicId, { targetPostNumber: nested ? parentPostNumber : postNumber, quoteSourceTarget: source, }); return true; } clearJumpBackLinks(ctx); if (nested) { if (!source.postData) return false; const navigationSequence = beginReaderNavigation(ctx); if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition(); const postData = Object.assign({}, source.postData || {}, { post_number: postNumber, reply_to_post_number: parentPostNumber, }); return focusLiveNestedReply(postData, ctx, { forceNested: true, navigationSequence }); } return !!ctx.timeline && ctx.timeline.jumpTo(postNumber); } function ingestLivePosts(posts, ctx, options = {}) { const ordered = (Array.isArray(posts) ? posts : []) .filter((post) => post && post.id && post.post_number) .sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0)); if (!ordered.length) return []; const fresh = []; ordered.forEach((post) => { if (!updateLivePostData(post, ctx, options)) fresh.push(post); }); if (fresh.length) attachPostBatch(fresh, ctx, { syncStreamNow: true }); const freshPostNumbers = new Set(fresh.map((post) => +(post.post_number || 0))); ordered.forEach((post) => syncLiveNestedReply(post, ctx, { incrementTotal: freshPostNumbers.has(+(post.post_number || 0)), })); if (ctx.isPostVoting) { ctx.streamItems.sort(comparePostVotingStreamItems); reindexStreamItems(ctx, 0); invalidateStreamLayout(ctx); } updateCommentsHeader(ctx); if (ctx.timeline) ctx.timeline.update(); return fresh; } function selectedQuoteRaw(post, ctx, selectedText) { const postData = post && post._ldpPostData || {}; const username = cleanUsernameValue(postData.username || post && post.dataset.username); const postNumber = +(post && post.dataset.postNumber || 0); const topicId = +(ctx && ctx.topicId || 0); const text = String(selectedText || '').trim(); if (!username || !postNumber || !topicId || !text) return ''; return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n${text}\n[/quote]\n\n`; } async function writeSelectionQuoteToClipboard(raw) { if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { await navigator.clipboard.writeText(raw); return; } const textarea = document.createElement('textarea'); textarea.value = raw; textarea.readOnly = true; textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;'; appendReaderPortalNode(textarea); textarea.select(); const copied = document.execCommand('copy'); textarea.remove(); if (!copied) throw new Error('clipboard unavailable'); } function showSelectionToast(message) { const existingToast = readerPortalQuery('.ldp-selection-toast'); if (existingToast) { existingToast.remove(); } const toast = document.createElement('div'); toast.className = 'ldp-selection-toast'; toast.setAttribute('role', 'status'); toast.textContent = message; appendReaderPortalNode(toast); setTimeout(() => toast.remove(), 1800); } async function shareReaderLink(ctx, postNode = null) { const sharingPost = !!postNode; const postNumber = +(postNode && postNode.dataset.postNumber || 0); if (!ctx || !ctx.topicId || (sharingPost && !postNumber)) return; const title = String(ctx && ctx.topicData && ctx.topicData.title || document.title || 'LINUX DO'); const url = `${BASE}/t/${ctx.topicId}${sharingPost ? `/${postNumber}` : ''}`; if (!sharingPost && navigator.share) { try { await navigator.share({ title, url }); return; } catch (error) { if (error && error.name === 'AbortError') return; } } try { await writeSelectionQuoteToClipboard(url); showSelectionToast(sharingPost ? `楼层 #${postNumber} 链接已复制到剪切板` : '帖子链接已复制到剪切板'); } catch (error) { showSelectionToast(sharingPost ? '复制楼层链接失败,请重试' : '复制链接失败,请重试'); } } function syncReaderBookmarkState(ctx, type, itemId, bookmarked, bookmarkId, options = {}) { if (type === 'Post') { const updated = []; readerPostCopies(itemId, ctx, true).forEach((node) => { const postData = node._ldpPostData || options.fallback; if (!postData) return; node._ldpPostData = postData; Object.assign(postData, { bookmarked, bookmark_id: bookmarkId }); syncPostActionControls(node, ctx, { bookmarkBusy: options.busy === true }); if (options.persist === true) persistReaderPostState(ctx, postData); updated.push(postData); }); return updated; } const topicData = options.fallback || ctx.topicData; ctx.topicBookmarked = bookmarked; if (topicData) { Object.assign(topicData, { bookmarked, bookmark_id: bookmarkId }); if (options.persist === true) setCachedTopicData(ctx.topicId, topicData); } syncTopicActionControls(ctx, { bookmarkBusy: options.busy === true }); return topicData ? [topicData] : []; } async function toggleReaderBookmark(subject, ctx, type) { const postBookmark = type === 'Post'; const item = postBookmark ? subject && subject._ldpPostData : subject; if (!item || !item.id || !ME_USERNAME) return; return runReaderActionOnce(ctx, `${type.toLowerCase()}:${item.id}:bookmark`, async () => { let { bookmarked, bookmarkId } = postBookmark ? postBookmarkInfo(item) : { bookmarked: !!item.bookmarked, bookmarkId: item.bookmark_id || null }; const original = { bookmarked, bookmarkId }; const apply = (busy = false) => syncReaderBookmarkState( ctx, type, item.id, bookmarked, bookmarkId, { busy, fallback: item } ); let mutationSucceeded = false; apply(true); try { if (!bookmarked) { const data = await apiSend(`${BASE}/bookmarks`, 'POST', { bookmarkable_id: item.id, bookmarkable_type: type, }); bookmarkId = data && (data.id || (data.bookmark && data.bookmark.id)) || bookmarkId; bookmarked = true; } else { if (!bookmarkId && postBookmark) { const fresh = await fetchJSON(`${BASE}/posts/${item.id}.json`, { cacheMode: 'refresh', allowStaleOnError: false, }); bookmarkId = fresh && fresh.bookmark_id; } if (bookmarkId) { await apiSend(`${BASE}/bookmarks/${bookmarkId}`, 'DELETE', null, null, { invalidateTags: [`${type.toLowerCase()}:${item.id}`], }); } else if (postBookmark) { throw new Error('缺少楼层书签编号'); } else { await apiSend(`${BASE}/t/${item.id}/remove_bookmarks`, 'PUT'); } bookmarked = false; bookmarkId = null; } mutationSucceeded = true; if (typeof ctx.refreshBookmarksPanel === 'function') ctx.refreshBookmarksPanel(); if (postBookmark) showSelectionToast(bookmarked ? '已收藏该楼层' : '已取消楼层收藏'); } catch (error) { if (postBookmark || !mutationSucceeded) ({ bookmarked, bookmarkId } = original); alert(`${postBookmark ? '楼层' : ''}收藏操作失败:${error.message}`); } finally { const updated = apply(false); if (postBookmark) { if (updated[0]) persistReaderPostState(ctx, updated[0]); } else { setCachedTopicData(ctx.topicId, item); } } }); } const READER_REPORT_OPTIONS = [ { id: 6, nameKey: 'spam', label: '垃圾信息', description: '广告、恶意推广或明显无关的内容。' }, { id: 4, nameKey: 'inappropriate', label: '不当内容', description: '违反社区文明交流规则的内容。' }, { id: 3, nameKey: 'off_topic', label: '偏离主题', description: '与当前主题无关,影响讨论阅读。' }, { id: 8, nameKey: 'notify_moderators', label: '通知管理人员', description: '需要管理人员结合补充说明进行判断。', requireMessage: true }, ]; function readerReportOptionsHtml(options) { return options.map((option, index) => ` <label class="ldp-reader-action-option"> <input type="radio" name="report_type" value="${option.id}"${index === 0 ? ' checked' : ''}> <span class="ldp-reader-action-option-copy"> <strong>${esc(option.label)}</strong> <small>${esc(option.description)}</small> </span> </label>`).join(''); } function openReaderReportForm(ctx, config) { const reportOptions = config.options || READER_REPORT_OPTIONS; return openReaderActionDialog(ctx, { title: config.title, intro: config.intro ?? '举报会直接提交给社区,不会离开阅读器。', submitLabel: '提交举报', initialFocus: 'input[name="report_type"]', bodyHtml: ` <div class="ldp-reader-action-options">${readerReportOptionsHtml(reportOptions)}</div> <label class="ldp-reader-action-field"> <span>补充说明</span> <textarea name="message" maxlength="1000" placeholder="${escAttr(config.placeholder || '选填;通知管理人员时请填写具体原因')}"></textarea> </label>`, onSubmit: async (formData) => { const actionType = +(formData.get('report_type') || 0); const message = String(formData.get('message') || '').trim(); const selectedOption = reportOptions.find((option) => option.id === actionType); if (!selectedOption) throw new Error('请选择举报类型'); if (selectedOption.requireMessage && !message) throw new Error( config.requiredMessageError || `${selectedOption.label}需要填写具体原因` ); return config.onSubmit(actionType, message); }, }); } function openReaderActionDialog(ctx, options) { if (!ctx || !ctx.modal) return null; if (ctx.readerActionDialog) ctx.readerActionDialog.close(); const layer = document.createElement('div'); layer.className = 'ldp-reader-action-layer'; layer.innerHTML = ` <section class="ldp-reader-action-dialog" role="dialog" aria-modal="true" aria-labelledby="ldp-reader-action-title"> <div class="ldp-reader-action-head"> <strong id="ldp-reader-action-title">${esc(options.title || '操作')}</strong> <button class="ldp-reader-action-close" type="button" aria-label="关闭">${icon('x')}</button> </div> <form class="ldp-reader-action-form"> <div class="ldp-reader-action-body"> ${options.intro ? `<p class="ldp-reader-action-intro">${esc(options.intro)}</p>` : ''} ${options.bodyHtml || ''} <p class="ldp-reader-action-status" role="status" aria-live="polite"></p> </div> <div class="ldp-reader-action-footer"> <button class="ldp-reader-action-cancel" type="button">取消</button> <button class="ldp-reader-action-submit" type="submit">${esc(options.submitLabel || '提交')}</button> </div> </form> </section>`; const form = layer.querySelector('.ldp-reader-action-form'); const status = layer.querySelector('.ldp-reader-action-status'); const submit = layer.querySelector('.ldp-reader-action-submit'); let closeTimer = 0; let busy = false; const close = () => { if (closeTimer) clearTimeout(closeTimer); layer.remove(); if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) { ctx.readerActionDialog = null; } }; const setBusy = (nextBusy) => { busy = nextBusy; form.querySelectorAll('input,textarea,select,button').forEach((control) => { control.disabled = nextBusy; }); submit.textContent = nextBusy ? '提交中…' : (options.submitLabel || '提交'); }; layer.querySelector('.ldp-reader-action-close').addEventListener('click', close); layer.querySelector('.ldp-reader-action-cancel').addEventListener('click', close); bindFloatingSurfaceWheel(layer); layer.addEventListener('click', (event) => { if (event.target === layer && !busy) close(); }); form.addEventListener('submit', async (event) => { event.preventDefault(); if (busy) return; status.classList.remove('success'); status.textContent = ''; setBusy(true); try { const successMessage = await options.onSubmit(new FormData(form)); status.classList.add('success'); status.textContent = successMessage || '操作已完成'; closeTimer = setTimeout(close, 650); } catch (error) { status.textContent = error && error.message ? error.message : '操作失败,请重试'; setBusy(false); } }); const dialogState = { layer, close }; ctx.readerActionDialog = dialogState; ctx.modal.appendChild(layer); requestAnimationFrame(() => { const focusTarget = layer.querySelector(options.initialFocus || 'input,textarea,button'); if (focusTarget) focusTarget.focus(); }); return dialogState; } function openReaderConfirmDialog(ctx, options) { if (!ctx || !ctx.modal) return null; if (ctx.readerActionDialog) ctx.readerActionDialog.close(); const layer = document.createElement('div'); const previouslyFocused = document.activeElement; const note = String(options.note || '').trim(); const details = Array.isArray(options.details) ? options.details.filter((detail) => detail && detail.label) : []; const secondaryLabel = String(options.secondaryLabel || '').trim(); layer.className = 'ldp-reader-action-layer ldp-reader-confirm-layer'; layer.innerHTML = ` <section class="ldp-reader-action-dialog ldp-reader-confirm-dialog${options.tone === 'primary' ? ' is-primary' : ''}" role="alertdialog" aria-modal="true" aria-labelledby="ldp-reader-confirm-title" aria-describedby="ldp-reader-confirm-description${details.length ? ' ldp-reader-confirm-details' : ''}${note ? ' ldp-reader-confirm-note' : ''}"> <div class="ldp-reader-confirm-content"> <span class="ldp-reader-confirm-icon" aria-hidden="true">${icon(options.icon || 'alertTriangle')}</span> <div class="ldp-reader-confirm-copy"> <strong id="ldp-reader-confirm-title">${esc(options.title || '确认操作')}</strong> <p id="ldp-reader-confirm-description">${esc(options.message || '')}</p> ${details.length ? `<ul class="ldp-reader-confirm-details" id="ldp-reader-confirm-details">${details.map((detail) => `<li><span>${esc(detail.label)}</span><strong>${esc(detail.value || '')}</strong></li>`).join('')}</ul>` : ''} ${note ? `<small id="ldp-reader-confirm-note">${esc(note)}</small>` : ''} </div> </div> <div class="ldp-reader-action-footer"> <button class="ldp-reader-action-cancel" type="button">${esc(options.cancelLabel || '取消')}</button> ${secondaryLabel ? `<button class="ldp-reader-action-secondary" type="button">${esc(secondaryLabel)}</button>` : ''} <button class="ldp-reader-action-submit" type="button">${esc(options.confirmLabel || '确认')}</button> </div> </section>`; const cancelButton = layer.querySelector('.ldp-reader-action-cancel'); const secondaryButton = layer.querySelector('.ldp-reader-action-secondary'); const confirmButton = layer.querySelector('.ldp-reader-action-submit'); let closed = false; const close = () => { if (closed) return; closed = true; layer.remove(); if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) ctx.readerActionDialog = null; requestAnimationFrame(() => { if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') { previouslyFocused.focus(); } }); }; cancelButton.addEventListener('click', close); if (secondaryButton) secondaryButton.addEventListener('click', () => { if (closed) return; if (typeof options.onSecondary === 'function') options.onSecondary(); close(); }); bindFloatingSurfaceWheel(layer); confirmButton.addEventListener('click', () => { if (closed) return; if (typeof options.onConfirm === 'function') options.onConfirm(); close(); }); layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); layer.addEventListener('keydown', (event) => { if (event.key !== 'Tab') return; const buttons = [cancelButton, secondaryButton, confirmButton].filter(Boolean); if (event.shiftKey && document.activeElement === buttons[0]) { event.preventDefault(); buttons[buttons.length - 1].focus(); } else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) { event.preventDefault(); buttons[0].focus(); } }); const dialogState = { layer, close }; ctx.readerActionDialog = dialogState; (ctx.modal.closest('.ldp-overlay') || ctx.modal).appendChild(layer); requestAnimationFrame(() => { if (layer.isConnected) cancelButton.focus(); }); return dialogState; } function openReaderReportDialog(postNode, ctx) { const post = postNode && (postNode._ldpPostData || {}); const postId = +(post && post.id || postNode && postNode.dataset.postId || 0); const postNumber = +(post && post.post_number || postNode && postNode.dataset.postNumber || 0); if (!postId || !postNumber) return; openReaderReportForm(ctx, { title: `举报 #${postNumber}`, requiredMessageError: '通知管理人员时需要填写具体原因', onSubmit: async (actionType, message) => { const params = { id: postId, post_action_type_id: actionType, flag_topic: postNumber === 1, }; if (message) params.message = message; await apiSend(`${BASE}/post_actions`, 'POST', params); post.can_flag = false; persistReaderPostState(ctx, post); syncAllPostActionControls(ctx); syncTopicActionControls(ctx); return '举报已提交'; }, }); } function openReaderAssignDialog(postNode, ctx, targetType) { const post = postNode && (postNode._ldpPostData || {}); const isTopic = targetType === 'Topic'; const targetId = isTopic ? +ctx.topicId : +(post && post.id || postNode && postNode.dataset.postId || 0); const postNumber = +(post && post.post_number || postNode && postNode.dataset.postNumber || 0); if (!targetId) return; const currentAssignment = isTopic ? ctx.topicData && ctx.topicData.assigned_to_user : post && post.assigned_to_user; const currentUsername = currentAssignment && currentAssignment.username || ''; openReaderActionDialog(ctx, { title: isTopic ? '指定主题负责人' : `指定 #${postNumber} 负责人`, intro: '输入社区用户名后直接提交,不会离开阅读器。', submitLabel: '确认指定', initialFocus: 'input[name="username"]', bodyHtml: ` <label class="ldp-reader-action-field"> <span>用户名</span> <input name="username" type="text" value="${escAttr(currentUsername)}" maxlength="100" autocomplete="off" placeholder="不含 @" required> </label> <label class="ldp-reader-action-field"> <span>备注(选填)</span> <textarea name="note" maxlength="1000" placeholder="说明这次指定的原因或任务"></textarea> </label>`, onSubmit: async (formData) => { const username = String(formData.get('username') || '').trim().replace(/^@+/, ''); const note = String(formData.get('note') || '').trim(); if (!username) throw new Error('请输入要指定的用户名'); const params = { username, target_id: targetId, target_type: targetType, topic_id: +ctx.topicId, }; if (!isTopic) params.post_id = targetId; if (note) params.note = note; await apiSend(`${BASE}/assign/assign`, 'PUT', params); const assignment = { username }; if (isTopic && ctx.topicData) { ctx.topicData.assigned_to_user = assignment; setCachedTopicData(ctx.topicId, ctx.topicData); } if (!isTopic && post) { post.assigned_to_user = assignment; persistReaderPostState(ctx, post); } return `已指定给 @${username}`; }, }); } /* ============ 10. 事件委托 ============ */ function bindActions(modal, ctx) { const floorPreview = bindFloorPreview(modal, ctx); const actionEventBindings = []; ctx.floorPreview = floorPreview; const userCardEventRoot = ensureReaderPortal() || modal.closest('.ldp-overlay') || modal; let userCardPrefetchTimer = 0; let userCardShowTimer = 0; let userCardHideTimer = 0; const cancelUserCardOpening = () => { clearTimeout(userCardPrefetchTimer); clearTimeout(userCardShowTimer); userCardPrefetchTimer = 0; userCardShowTimer = 0; }; let userCardToken = 0; let userCardAnchor = null; let userCardPositionFrame = 0; let userCardAnchorObserver = null; const fallbackUserCard = getFallbackUserCard(); const userCardFollowPanel = getUserCardFollowPanel(); const userCardFollowPreview = getUserCardFollowPreview(); let userCardFollowPreviewAnchor = null; let userCardFollowPreviewToken = 0; let userCardFollowPreviewShowTimer = 0; let userCardFollowPreviewHoverArmed = true; const userCardNavigationStack = []; const selectionToolbar = document.createElement('div'); selectionToolbar.className = 'ldp-selection-toolbar ldp-action-surface'; selectionToolbar.hidden = true; selectionToolbar.setAttribute('role', 'toolbar'); selectionToolbar.setAttribute('aria-label', '引用所选文字'); selectionToolbar.innerHTML = ` <button type="button" data-selection-action="quote">引用</button> <button type="button" data-selection-action="copy">复制引用</button>`; appendReaderPortalNode(selectionToolbar); const imageQuoteToolbar = document.createElement('div'); imageQuoteToolbar.className = 'ldp-selection-toolbar ldp-image-quote-toolbar ldp-action-surface'; imageQuoteToolbar.hidden = true; imageQuoteToolbar.setAttribute('role', 'toolbar'); imageQuoteToolbar.setAttribute('aria-label', '引用图片'); imageQuoteToolbar.innerHTML = '<button type="button">引用图片</button>'; appendReaderPortalNode(imageQuoteToolbar); const selectionRoot = modal.getRootNode(); let selectedQuote = null; let selectionToolbarFrame = 0; let imageQuoteTarget = null; let imageQuotePositionFrame = 0; let imageQuoteHideTimer = 0; let imageQuoteShowTimer = 0; let imageQuoteCycleTimer = 0; let imageQuotePointerX = 0; let imageQuotePointerY = 0; let imageQuoteAnchorX = 0; let imageQuoteAnchorY = 0; let imageQuotePointerInside = false; let activeActionPost = null; const reactionHoverOpenTimers = new Map(); const reactionHoverCloseTimers = new Map(); let reactionVisibilityObserver = null; let reactionRegistryHydration = null; const clearReactionHoverTimer = (timers, root) => { const timer = timers.get(root); if (!timer) return; clearTimeout(timer); timers.delete(root); }; const setReactionPickerExpanded = (root, open) => { clearReactionHoverTimer(reactionHoverOpenTimers, root); clearReactionHoverTimer(reactionHoverCloseTimers, root); if (!root || !root.isConnected) return; const picker = root.querySelector('.ldp-reaction-picker'); const trigger = root.querySelector('[data-reaction-picker]'); if (picker) picker.hidden = !open; if (trigger) { trigger.setAttribute('aria-expanded', String(!!open)); trigger.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); } if (open) reactionVisibilityObserver?.observe(root); else reactionVisibilityObserver?.unobserve(root); }; const hydrateReactionRegistry = () => { if (reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls); if (reactionRegistryHydration) return reactionRegistryHydration; reactionRegistryHydration = loadReactionEmojiRegistry().then(() => { if (!reactionEmojiUrls || !modal.isConnected || (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active'))) return; modal.querySelectorAll('.ldp-post').forEach((postNode) => { const root = postNode.querySelector(':scope > .ldp-reactions'); const wasExpanded = !!(root && root.querySelector('.ldp-reaction-picker:not([hidden])')); renderPostReactions(postNode, ctx); if (wasExpanded) setReactionPickerExpanded(postNode.querySelector(':scope > .ldp-reactions'), true); }); }).finally(() => { reactionRegistryHydration = null; }); return reactionRegistryHydration; }; hydrateReactionRegistry(); if (typeof IntersectionObserver === 'function') { reactionVisibilityObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) return; const root = entry.target; setReactionPickerExpanded(root, false); }); }, { root: ctx.scrollRoot, threshold: 0 }); } const onReactionPointerOver = (event) => { const root = event.target.closest && event.target.closest('.ldp-reactions'); if (!root || !modal.contains(root)) return; clearReactionHoverTimer(reactionHoverCloseTimers, root); const trigger = event.target.closest('[data-reaction-picker]'); if (!trigger || (event.relatedTarget && trigger.contains(event.relatedTarget))) return; hydrateReactionRegistry(); clearReactionHoverTimer(reactionHoverOpenTimers, root); reactionHoverOpenTimers.set(root, setTimeout(() => { reactionHoverOpenTimers.delete(root); setReactionPickerExpanded(root, true); }, 500)); }; const onReactionPointerOut = (event) => { const root = event.target.closest && event.target.closest('.ldp-reactions'); if (!root || !modal.contains(root)) return; const trigger = event.target.closest('[data-reaction-picker]'); if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) { clearReactionHoverTimer(reactionHoverOpenTimers, root); } if (event.relatedTarget && root.contains(event.relatedTarget)) return; clearReactionHoverTimer(reactionHoverOpenTimers, root); clearReactionHoverTimer(reactionHoverCloseTimers, root); reactionHoverCloseTimers.set(root, setTimeout(() => { reactionHoverCloseTimers.delete(root); setReactionPickerExpanded(root, false); }, 500)); }; const activatePostActions = (event) => { const post = event.target.closest && event.target.closest('.ldp-post'); if (!post || !modal.contains(post)) return; if (activeActionPost !== post) { if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active'); activeActionPost = post; activeActionPost.classList.add('ldp-actions-active'); } hydratePostContextActions(post, ctx); }; const clearActivePostActions = () => { if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active'); activeActionPost = null; }; addTrackedEventListener(actionEventBindings, modal, 'pointerover', activatePostActions); addTrackedEventListener(actionEventBindings, modal, 'pointerover', onReactionPointerOver); addTrackedEventListener(actionEventBindings, modal, 'pointerout', onReactionPointerOut); addTrackedEventListener(actionEventBindings, modal, 'focusin', activatePostActions); addTrackedEventListener(actionEventBindings, modal, 'pointerleave', clearActivePostActions); const hideSelectionToolbar = () => { selectionToolbar.hidden = true; selectedQuote = null; }; const clearSelectedText = () => { const selection = readerSelection(); if (selection && typeof selection.removeAllRanges === 'function') selection.removeAllRanges(); }; const updateSelectionToolbar = () => { const selection = readerSelection(); if (!selection || selection.isCollapsed || !selection.rangeCount) return hideSelectionToolbar(); const text = String(selection.toString() || '').trim(); if (!text) return hideSelectionToolbar(); const range = selection.getRangeAt(0); const startNode = range.startContainer.nodeType === Node.ELEMENT_NODE ? range.startContainer : range.startContainer.parentElement; const endNode = range.endContainer.nodeType === Node.ELEMENT_NODE ? range.endContainer : range.endContainer.parentElement; const startContent = startNode && startNode.closest && startNode.closest('.ldp-content'); const endContent = endNode && endNode.closest && endNode.closest('.ldp-content'); if (!startContent || startContent !== endContent || !modal.contains(startContent)) return hideSelectionToolbar(); const post = startContent.closest('.ldp-post'); const raw = selectedQuoteRaw(post, ctx, text); if (!post || !raw) return hideSelectionToolbar(); const rect = range.getBoundingClientRect(); if (!rect || (!rect.width && !rect.height)) return hideSelectionToolbar(); selectedQuote = { post, raw }; selectionToolbar.hidden = false; const toolbarRect = selectionToolbar.getBoundingClientRect(); const left = Math.max(8, Math.min( rect.right - toolbarRect.width, window.innerWidth - toolbarRect.width - 8, )); const above = rect.top - toolbarRect.height - 8; const top = above >= 8 ? above : Math.min(window.innerHeight - toolbarRect.height - 8, rect.bottom + 8); selectionToolbar.style.left = `${Math.round(left)}px`; selectionToolbar.style.top = `${Math.round(Math.max(8, top))}px`; }; const scheduleSelectionToolbar = () => { if (selectionToolbarFrame) return; selectionToolbarFrame = requestAnimationFrame(() => { selectionToolbarFrame = 0; updateSelectionToolbar(); }); }; const onSelectionPointerDown = (event) => { if (selectionToolbar.contains(event.target) || imageQuoteToolbar.contains(event.target)) event.preventDefault(); }; const onDocumentPointerDown = (event) => { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; if (!selectionToolbar.hidden && !path.includes(selectionToolbar) && !selectionToolbar.contains(event.target)) { hideSelectionToolbar(); } }; const onSelectionAction = async (event) => { const button = event.target.closest('[data-selection-action]'); if (!button || !selectedQuote) return; const { post, raw } = selectedQuote; if (button.dataset.selectionAction === 'quote') { await tryOpenNativeReplyComposer(post, ctx, raw); hideSelectionToolbar(); clearSelectedText(); return; } try { await writeSelectionQuoteToClipboard(raw); showSelectionToast('引用已复制到剪切板'); } catch (error) { showSelectionToast('复制失败,请重试'); } hideSelectionToolbar(); clearSelectedText(); }; const clearImageQuoteCycleTimers = () => { clearTimeout(imageQuoteShowTimer); clearTimeout(imageQuoteCycleTimer); imageQuoteShowTimer = 0; imageQuoteCycleTimer = 0; }; const hideImageQuoteToolbar = () => { clearTimeout(imageQuoteHideTimer); clearImageQuoteCycleTimers(); imageQuoteHideTimer = 0; imageQuoteTarget = null; imageQuotePointerInside = false; imageQuoteToolbar.hidden = true; }; const closeTransientActions = () => { if (!selectionToolbar.hidden) { hideSelectionToolbar(); clearSelectedText(); return true; } if (!imageQuoteToolbar.hidden) { hideImageQuoteToolbar(); return true; } const reactionPicker = modal.querySelector('.ldp-reaction-picker:not([hidden])'); if (!reactionPicker) return false; const root = reactionPicker.closest('.ldp-reactions'); setReactionPickerExpanded(root, false); return true; }; ctx.closeTransientActions = closeTransientActions; const scheduleHideImageQuoteToolbar = () => { clearTimeout(imageQuoteHideTimer); imageQuoteHideTimer = setTimeout(hideImageQuoteToolbar, 140); }; const positionImageQuoteToolbar = () => { imageQuotePositionFrame = 0; if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return hideImageQuoteToolbar(); imageQuoteToolbar.hidden = false; const toolbarRect = imageQuoteToolbar.getBoundingClientRect(); const gap = 12; const edge = 8; let left = imageQuoteAnchorX + gap; let top = imageQuoteAnchorY + gap; if (left + toolbarRect.width > window.innerWidth - edge) { left = imageQuoteAnchorX - toolbarRect.width - gap; } if (top + toolbarRect.height > window.innerHeight - edge) top = imageQuoteAnchorY - toolbarRect.height - gap; imageQuoteToolbar.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - toolbarRect.width - edge)))}px`; imageQuoteToolbar.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - toolbarRect.height - edge)))}px`; }; const scheduleImageQuoteToolbarPosition = () => { if (imageQuotePositionFrame) return; imageQuotePositionFrame = requestAnimationFrame(positionImageQuoteToolbar); }; const startImageQuoteCycle = () => { clearImageQuoteCycleTimers(); imageQuoteToolbar.hidden = true; if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return; imageQuoteShowTimer = setTimeout(() => { imageQuoteShowTimer = 0; if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return; imageQuoteAnchorX = imageQuotePointerX; imageQuoteAnchorY = imageQuotePointerY; scheduleImageQuoteToolbarPosition(); imageQuoteCycleTimer = setTimeout(() => { imageQuoteCycleTimer = 0; if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) { hideImageQuoteToolbar(); return; } startImageQuoteCycle(); }, 5000); }, 350); }; const updateImageQuotePointer = (event) => { const image = lightboxImageFromEvent(event); if (!image) return; const shouldRestart = imageQuoteTarget !== image || !imageQuotePointerInside; clearTimeout(imageQuoteHideTimer); imageQuoteHideTimer = 0; imageQuoteTarget = image; imageQuotePointerInside = true; imageQuotePointerX = event.clientX; imageQuotePointerY = event.clientY; if (shouldRestart) startImageQuoteCycle(); }; const onImageQuotePointerUpdate = (event) => { updateImageQuotePointer(event); }; const onImageQuotePointerOut = (event) => { if (!lightboxImageFromEvent(event)) return; imageQuotePointerInside = false; clearImageQuoteCycleTimers(); const related = event.relatedTarget; if (related && (imageQuoteToolbar === related || imageQuoteToolbar.contains(related))) return; scheduleHideImageQuoteToolbar(); }; const keepImageQuoteToolbarOpen = () => { clearTimeout(imageQuoteHideTimer); imageQuoteHideTimer = 0; imageQuotePointerInside = false; clearImageQuoteCycleTimers(); }; const onImageQuoteAction = async (event) => { event.preventDefault(); const image = imageQuoteTarget; const post = image && image.closest('.ldp-post'); const imageSet = image && image.isConnected ? collectLightboxItems(image, ctx) : null; const item = imageSet && imageSet.items[imageSet.initialIndex]; const raw = lightboxImageQuoteRaw(item, ctx); hideImageQuoteToolbar(); if (!post || !raw) { showSelectionToast('无法确认图片引用来源'); return; } await tryOpenNativeReplyComposer(post, ctx, raw); }; addTrackedEventListener(actionEventBindings, modal, 'mouseup', scheduleSelectionToolbar); addTrackedEventListener(actionEventBindings, modal, 'keyup', scheduleSelectionToolbar); addTrackedEventListener(actionEventBindings, modal, 'pointerover', onImageQuotePointerUpdate); addTrackedEventListener(actionEventBindings, modal, 'pointermove', onImageQuotePointerUpdate); addTrackedEventListener(actionEventBindings, modal, 'pointerout', onImageQuotePointerOut); addTrackedEventListener(actionEventBindings, selectionToolbar, 'pointerdown', onSelectionPointerDown); addTrackedEventListener(actionEventBindings, selectionToolbar, 'click', onSelectionAction); addTrackedEventListener(actionEventBindings, imageQuoteToolbar, 'pointerdown', onSelectionPointerDown); addTrackedEventListener(actionEventBindings, imageQuoteToolbar, 'pointerenter', keepImageQuoteToolbarOpen); addTrackedEventListener(actionEventBindings, imageQuoteToolbar, 'pointerleave', scheduleHideImageQuoteToolbar); addTrackedEventListener(actionEventBindings, imageQuoteToolbar, 'click', onImageQuoteAction); addTrackedEventListener(actionEventBindings, document, 'selectionchange', scheduleSelectionToolbar); if (selectionRoot && selectionRoot !== document && typeof selectionRoot.addEventListener === 'function') { addTrackedEventListener(actionEventBindings, selectionRoot, 'selectionchange', scheduleSelectionToolbar); } addTrackedEventListener(actionEventBindings, document, 'pointerdown', onDocumentPointerDown, true); addTrackedEventListener(actionEventBindings, ctx.scrollRoot, 'scroll', hideSelectionToolbar, { passive: true }); addTrackedEventListener(actionEventBindings, ctx.scrollRoot, 'scroll', hideImageQuoteToolbar, { passive: true }); const closeUserCardFollowPreview = (options = {}) => { userCardFollowPreviewToken++; userCardFollowPreviewAnchor = null; clearTimeout(userCardFollowPreviewShowTimer); userCardFollowPreviewShowTimer = 0; if (options.suppressHoverUntilExit) userCardFollowPreviewHoverArmed = false; userCardFollowPreviewActionIntent = null; releasePersistentAvatarImages(userCardFollowPreview); userCardFollowPreview.classList.remove('open'); delete userCardFollowPreview._ldpUserData; renderUserCardBreadcrumbs(); }; const navigationEntryFromUser = (user) => ({ username: String(user && user.username || ''), name: String(user && (user.name || user.username) || ''), user, followType: '', followUsers: null, search: '', page: 0, }); const ensureUserCardNavigationRoot = () => { const user = fallbackUserCard._ldpUserData; const username = String(user && user.username || ''); if (!username) return null; if (!userCardNavigationStack.length) userCardNavigationStack.push(navigationEntryFromUser(user)); return userCardNavigationStack[userCardNavigationStack.length - 1]; }; const rememberCurrentUserCardNavigationState = () => { const entry = ensureUserCardNavigationRoot(); if (!entry) return; const activeCard = userCardNavigationStack.length > 1 ? userCardFollowPreview : fallbackUserCard; const displayedUser = activeCard._ldpUserData; if (String(displayedUser && displayedUser.username || '') === entry.username) { entry.user = displayedUser; } entry.name = String(entry.user && (entry.user.name || entry.user.username) || entry.username); if (userCardFollowPanel.hidden || userCardFollowPanel.dataset.followUsername !== entry.username) return; entry.followType = String(userCardFollowPanel.dataset.followType || ''); entry.followUsers = Array.isArray(userCardFollowPanel._ldpFollowUsers) ? userCardFollowPanel._ldpFollowUsers : null; entry.search = userCardFollowPanel.querySelector('input[type="search"]')?.value || ''; entry.page = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0); }; const renderUserCardBreadcrumbs = () => { const breadcrumbs = userCardFollowPanel.querySelector('.ldp-user-card-breadcrumbs'); if (!breadcrumbs) return; const entries = userCardNavigationStack.map((entry, index) => ({ ...entry, index })); const previewUser = userCardFollowPreview.classList.contains('open') && userCardFollowPreview._ldpUserData; if (previewUser && String(previewUser.username || '') !== String(entries[entries.length - 1]?.username || '')) { entries.push({ ...navigationEntryFromUser(previewUser), index: -1, preview: true }); } breadcrumbs.hidden = entries.length < 2; breadcrumbs.innerHTML = entries.map((entry, index) => { const label = entry.name || entry.username; const last = index === entries.length - 1; const crumb = last ? `<strong>${esc(label)}</strong>` : `<button type="button" data-user-card-breadcrumb-index="${entry.index}">${esc(label)}</button>`; return `${index ? '<span aria-hidden="true">›</span>' : ''}${crumb}`; }).join(''); breadcrumbs.scrollLeft = breadcrumbs.scrollWidth; }; const renderSecondaryUserCard = (user) => { if (!user || !user.username) return false; userCardFollowPreviewToken++; userCardFollowPreviewAnchor = null; clearTimeout(userCardFollowPreviewShowTimer); userCardFollowPreviewShowTimer = 0; releasePersistentAvatarImages(userCardFollowPreview); userCardFollowPreview._ldpUserData = user; userCardFollowPreview.innerHTML = renderFallbackUserCard( user, user.username, 'ready', user._ldp_avatar_preview_url || '' ); hydratePersistentAvatarImages(userCardFollowPreview, POST_REQUEST_PRIORITY.userCard); syncReaderSvgSymbols(userCardFollowPreview); syncUserCardBadgeScrollControls(userCardFollowPreview); requestAnimationFrame(() => userCardFollowPreview.isConnected && syncUserCardBadgeScrollControls(userCardFollowPreview)); userCardFollowPreview.classList.add('open'); positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard); return true; }; const clearUserCardFollowPanelState = () => { delete userCardFollowPanel._ldpFollowUsers; delete userCardFollowPanel.dataset.followType; delete userCardFollowPanel.dataset.followUsername; delete userCardFollowPanel.dataset.followPage; const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list'); if (list) { releasePersistentAvatarImages(list); list.replaceChildren(); } [fallbackUserCard, userCardFollowPreview].forEach((card) => card.querySelectorAll('[data-user-card-follow-type]') .forEach((button) => button.setAttribute('aria-expanded', 'false'))); }; const restoreUserCardNavigationEntry = (index) => { if (!Number.isInteger(index) || index < 0 || index >= userCardNavigationStack.length) return false; rememberCurrentUserCardNavigationState(); const entry = userCardNavigationStack[index]; userCardNavigationStack.length = index + 1; if (index === 0) closeUserCardFollowPreview(); else if (!renderSecondaryUserCard(entry.user)) return false; clearUserCardFollowPanelState(); if (entry.followType && Array.isArray(entry.followUsers)) { userCardFollowPanel.hidden = false; userCardFollowPanel.dataset.followType = entry.followType; userCardFollowPanel.dataset.followUsername = entry.username; userCardFollowPanel._ldpFollowUsers = entry.followUsers; userCardFollowPanel.querySelector('.ldp-user-card-follow-title').textContent = entry.followType === 'following' ? '关注的人员' : '被关注的人员'; const search = userCardFollowPanel.querySelector('input[type="search"]'); if (search) search.value = entry.search || ''; userCardFollowPanel.dataset.followPage = String(Math.max(0, Number(entry.page) || 0)); const activeCard = index === 0 ? fallbackUserCard : userCardFollowPreview; activeCard.querySelectorAll('[data-user-card-follow-type]').forEach((button) => button.setAttribute('aria-expanded', String(button.dataset.userCardFollowType === entry.followType))); renderUserCardFollowList(userCardFollowPanel); positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard); if (index > 0) positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard); } else { userCardFollowPanel.hidden = true; } renderUserCardBreadcrumbs(); return true; }; const promoteUserCardFollowPreview = () => { const user = userCardFollowPreview._ldpUserData; const username = String(user && user.username || ''); if (!username) return false; rememberCurrentUserCardNavigationState(); const current = userCardNavigationStack[userCardNavigationStack.length - 1]; if (!current || current.username !== username) userCardNavigationStack.push(navigationEntryFromUser(user)); else current.user = user; clearUserCardFollowPanelState(); renderUserCardBreadcrumbs(); return true; }; const closeUserCardFollowPanel = () => { closeUserCardFollowPreview(); if (userCardFollowPanel.hidden) return; userCardFollowPanel.hidden = true; userCardFollowPanel.style.removeProperty('max-height'); fallbackUserCard.style.removeProperty('max-height'); if (userCardNavigationStack.length > 1) userCardNavigationStack.length = 1; clearUserCardFollowPanelState(); if (fallbackUserCardOpen() && userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor); }; const closeUserCard = () => { const avatarViewer = readerPortalQuery('.ldp-avatar-viewer'); if (avatarViewer?._ldpAnchorCard === fallbackUserCard && typeof avatarViewer._ldpClose === 'function') { avatarViewer._ldpClose(); } closeUserCardFollowPanel(); cancelQueuedUserCardRequests(getUserCardUsername(userCardAnchor), ctx.postRequestScheduler); userCardToken++; userCardAnchor = null; cancelUserCardOpening(); clearTimeout(userCardHideTimer); userCardHideTimer = 0; if (userCardPositionFrame) cancelAnimationFrame(userCardPositionFrame); userCardPositionFrame = 0; userCardAnchorObserver?.disconnect(); userCardAnchorObserver = null; userCardNavigationStack.length = 0; hideFallbackUserCard(); }; const closeUserCardLayer = () => { if (userCardFollowPreview.classList.contains('open')) { const activeEntry = userCardNavigationStack[userCardNavigationStack.length - 1]; const displayedUsername = String(userCardFollowPreview._ldpUserData?.username || ''); if (userCardNavigationStack.length < 2 || displayedUsername !== activeEntry?.username) { if (userCardNavigationStack.length > 1 && activeEntry?.user) { userCardFollowPreviewHoverArmed = false; renderSecondaryUserCard(activeEntry.user); renderUserCardBreadcrumbs(); return; } return closeUserCardFollowPreview({ suppressHoverUntilExit: true }); } } if (userCardNavigationStack.length > 1) { return restoreUserCardNavigationEntry(userCardNavigationStack.length - 2); } if (!userCardFollowPanel.hidden) return closeUserCardFollowPanel(); const notificationMenu = fallbackUserCard.querySelector('.ldp-user-card-notification-menu:not([hidden])'); if (!notificationMenu) return closeUserCard(); notificationMenu.hidden = true; fallbackUserCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false'); if (userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor); }; ctx.closeUserCard = closeUserCard; ctx.closeUserCardLayer = closeUserCardLayer; const userCardAnchorIsVisible = (anchor) => { if (!anchor || !anchor.isConnected) return false; const rect = anchor.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth; }; const syncUserCardPosition = () => { userCardPositionFrame = 0; if (!fallbackUserCardOpen() || !userCardAnchor) return; if (!userCardAnchorIsVisible(userCardAnchor)) return closeUserCard(); positionFallbackUserCard(fallbackUserCard, userCardAnchor); positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard); if (userCardFollowPreview.classList.contains('open')) { positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard); } }; const scheduleUserCardPosition = () => { if (!fallbackUserCardOpen() || userCardPositionFrame) return; userCardPositionFrame = requestAnimationFrame(syncUserCardPosition); }; const observeUserCardAnchor = (anchor) => { userCardAnchorObserver?.disconnect(); userCardAnchorObserver = null; if (typeof IntersectionObserver !== 'function' || !anchor) return; userCardAnchorObserver = new IntersectionObserver((entries) => { const entry = entries.find((item) => item.target === anchor); if (userCardAnchor !== anchor || !entry) return; if (!entry.isIntersecting) closeUserCard(); else scheduleUserCardPosition(); }, { threshold: 0 }); userCardAnchorObserver.observe(anchor); }; addTrackedEventListener(actionEventBindings, document, 'scroll', scheduleUserCardPosition, true); addTrackedEventListener(actionEventBindings, window, 'scroll', scheduleUserCardPosition, { passive: true }); addTrackedEventListener(actionEventBindings, window, 'resize', scheduleUserCardPosition, { passive: true }); const scheduleCloseUserCard = (event) => { if (!userCardFollowPanel.hidden) return; const relatedTarget = event && event.relatedTarget; const avatarViewer = readerPortalQuery('.ldp-avatar-viewer'); if (relatedTarget && (fallbackUserCard.contains(relatedTarget) || userCardFollowPanel.contains(relatedTarget) || userCardFollowPreview.contains(relatedTarget) || avatarViewer?.contains(relatedTarget))) return; clearTimeout(userCardHideTimer); userCardHideTimer = setTimeout(() => { userCardHideTimer = 0; const currentAvatarViewer = readerPortalQuery('.ldp-avatar-viewer'); if (userCardAnchor?.matches(':hover') || fallbackUserCard.matches(':hover') || userCardFollowPanel.matches(':hover') || userCardFollowPreview.matches(':hover') || currentAvatarViewer?.matches(':hover')) return; closeUserCard(); }, 180); }; const keepUserCardOpen = () => clearTimeout(userCardHideTimer); const openUserCardMediaViewer = (...args) => { const viewer = openUserAvatarViewer(...args); if (!viewer) return; viewer.addEventListener('mouseenter', keepUserCardOpen); viewer.addEventListener('mouseleave', scheduleCloseUserCard); }; addTrackedEventListener(actionEventBindings, fallbackUserCard, 'mouseenter', keepUserCardOpen); addTrackedEventListener(actionEventBindings, fallbackUserCard, 'mouseleave', scheduleCloseUserCard); addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'mouseenter', keepUserCardOpen); addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'mouseleave', scheduleCloseUserCard); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'mouseenter', keepUserCardOpen); const onUserCardHostLinkClick = (event) => { const link = event.target.closest && event.target.closest('a[href]'); if (!link || (!fallbackUserCard.contains(link) && !userCardFollowPanel.contains(link) && !userCardFollowPreview.contains(link))) return; openEmbeddedReaderLinkInHost(event, link, ctx.readerWorkspace); }; addTrackedEventListener(actionEventBindings, fallbackUserCard, 'click', onUserCardHostLinkClick, true); addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'click', onUserCardHostLinkClick, true); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'click', onUserCardHostLinkClick, true); const onUserCardWheel = (event) => { event.stopPropagation(); const badgeList = event.target instanceof Element && event.target.closest('.ldp-user-card-badge-list'); if (badgeList && fallbackUserCard.contains(badgeList) && badgeList.scrollWidth > badgeList.clientWidth + 1) { const rawHorizontalDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; const horizontalDelta = event.deltaMode === 1 ? rawHorizontalDelta * 40 : event.deltaMode === 2 ? rawHorizontalDelta * badgeList.clientWidth : rawHorizontalDelta; const maxScrollLeft = badgeList.scrollWidth - badgeList.clientWidth; const canScrollHorizontally = horizontalDelta < 0 ? badgeList.scrollLeft > 0 : badgeList.scrollLeft < maxScrollLeft - 1; if (horizontalDelta && canScrollHorizontally) { event.preventDefault(); badgeList.scrollLeft += horizontalDelta; return; } } containFloatingSurfaceWheel(fallbackUserCard, event); }; addTrackedEventListener(actionEventBindings, fallbackUserCard, 'wheel', onUserCardWheel, { passive: false }); const onUserCardFollowPanelWheel = (event) => { const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list'); if (list && !list.contains(event.target) && event.deltaY && list.scrollHeight > list.clientHeight + 1) { const delta = event.deltaMode === 1 ? event.deltaY * 40 : event.deltaMode === 2 ? event.deltaY * list.clientHeight : event.deltaY; event.preventDefault(); event.stopPropagation(); list.scrollTop += delta; return; } containFloatingSurfaceWheel(userCardFollowPanel, event); }; addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'wheel', onUserCardFollowPanelWheel, { passive: false }); const onUserCardFollowPreviewWheel = (event) => { event.stopPropagation(); containFloatingSurfaceWheel(userCardFollowPreview, event); }; addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'wheel', onUserCardFollowPreviewWheel, { passive: false }); const onUserCardBadgeListScroll = (event) => { if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-badge-list')) return; syncUserCardBadgeScrollControls(event.currentTarget); }; addTrackedEventListener(actionEventBindings, fallbackUserCard, 'scroll', onUserCardBadgeListScroll, true); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'scroll', onUserCardBadgeListScroll, true); const setUserCardActionStatus = (card, message, isError = false) => { const status = card && card.querySelector('.ldp-user-card-action-status'); if (!status) return; status.textContent = message || ''; status.classList.toggle('is-error', !!isError); }; const onUserCardAction = async (event) => { const actionCard = userCardFollowPreview.contains(event.target) ? userCardFollowPreview : fallbackUserCard.contains(event.target) ? fallbackUserCard : null; if (actionCard === userCardFollowPreview) { const directControl = event.target.closest && event.target.closest( '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]' ); if (directControl && directControl !== userCardFollowPreviewActionIntent) return; if (directControl) userCardFollowPreviewActionIntent = null; } const followClose = event.target.closest && event.target.closest('[data-user-card-follow-close]'); if (followClose && userCardFollowPanel.contains(followClose)) { event.preventDefault(); event.stopPropagation(); closeUserCardLayer(); return; } const breadcrumb = event.target.closest && event.target.closest('[data-user-card-breadcrumb-index]'); if (breadcrumb && userCardFollowPanel.contains(breadcrumb)) { event.preventDefault(); event.stopPropagation(); restoreUserCardNavigationEntry(Number(breadcrumb.dataset.userCardBreadcrumbIndex)); return; } const pageButton = event.target.closest && event.target.closest('[data-user-card-follow-page]'); if (pageButton && userCardFollowPanel.contains(pageButton)) { event.preventDefault(); event.stopPropagation(); if (pageButton.disabled) return; const currentPage = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0); userCardFollowPanel.dataset.followPage = String(pageButton.dataset.userCardFollowPage === 'previous' ? Math.max(0, currentPage - 1) : currentPage + 1); restoreUserCardNavigationEntry(Math.max(0, userCardNavigationStack.length - 1)); return; } const followButton = event.target.closest && event.target.closest('[data-user-card-follow-type]'); if (followButton && actionCard && actionCard.contains(followButton)) { event.preventDefault(); event.stopPropagation(); if (followButton.disabled || followButton.dataset.busy === '1') return; clearTimeout(userCardHideTimer); const type = followButton.dataset.userCardFollowType; const username = cleanUsernameValue(followButton.dataset.username || actionCard._ldpUserData?.username); const panel = userCardFollowPanel; const anchor = userCardAnchor; if (!username || !panel || !anchor || !['following', 'followers'].includes(type)) return; cancelUserCardOpening(); const returnedToRoot = actionCard === fallbackUserCard && userCardNavigationStack.length > 1; if (actionCard === userCardFollowPreview) { if (!promoteUserCardFollowPreview()) return; } else if (returnedToRoot) { restoreUserCardNavigationEntry(0); } const navigationEntry = ensureUserCardNavigationRoot(); if (!panel.hidden && panel.dataset.followType === type && panel.dataset.followUsername === username) { if (returnedToRoot) return; closeUserCardFollowPanel(); return; } const notificationMenu = actionCard.querySelector('.ldp-user-card-notification-menu'); if (notificationMenu) notificationMenu.hidden = true; actionCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false'); [fallbackUserCard, userCardFollowPreview].forEach((card) => card.querySelectorAll('[data-user-card-follow-type]').forEach((button) => button.setAttribute('aria-expanded', String(card === actionCard && button === followButton)))); panel.hidden = false; panel.dataset.followType = type; panel.dataset.followUsername = username; panel.dataset.followPage = '0'; delete panel._ldpFollowUsers; if (navigationEntry) { navigationEntry.user = actionCard._ldpUserData || navigationEntry.user; navigationEntry.followType = type; navigationEntry.followUsers = null; navigationEntry.search = ''; navigationEntry.page = 0; } renderUserCardBreadcrumbs(); panel.querySelector('.ldp-user-card-follow-title').textContent = type === 'following' ? '关注的人员' : '被关注的人员'; const search = panel.querySelector('input[type="search"]'); const summary = panel.querySelector('.ldp-user-card-follow-summary'); const list = panel.querySelector('.ldp-user-card-follow-list'); search.value = ''; summary.textContent = '正在加载…'; releasePersistentAvatarImages(list); list.innerHTML = '<div class="ldp-user-card-follow-empty">正在加载人员列表…</div>'; positionUserCardFollowPanel(panel, fallbackUserCard); followButton.dataset.busy = '1'; try { const users = await fetchUserFollowList(username, type); if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden || panel.dataset.followType !== type || panel.dataset.followUsername !== username) return; panel._ldpFollowUsers = users; renderUserCardFollowList(panel); positionUserCardFollowPanel(panel, fallbackUserCard); if (actionCard === userCardFollowPreview) { positionUserCardFollowPreview(userCardFollowPreview, panel, fallbackUserCard); } rememberCurrentUserCardNavigationState(); renderUserCardBreadcrumbs(); search.focus({ preventScroll: true }); } catch (error) { if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden || panel.dataset.followType !== type || panel.dataset.followUsername !== username) return; summary.textContent = Number(error && error.status) === 429 ? '请求受限,请过盾后重试' : '人员列表加载失败'; list.innerHTML = `<div class="ldp-user-card-follow-empty">${esc(error && error.message || '请稍后重试')}</div>`; } finally { if (followButton.isConnected) delete followButton.dataset.busy; } return; } const badgeScrollButton = event.target.closest && event.target.closest('[data-user-card-badge-scroll]'); if (badgeScrollButton && actionCard && actionCard.contains(badgeScrollButton)) { event.preventDefault(); event.stopPropagation(); if (badgeScrollButton.disabled) return; clearTimeout(userCardHideTimer); const list = badgeScrollButton.closest('.ldp-user-card-badge-strip') ?.querySelector('.ldp-user-card-badge-list'); if (!list) return; const direction = Number(badgeScrollButton.dataset.userCardBadgeScroll) || 1; list.scrollBy({ left: direction * Math.max(96, list.clientWidth * .78), behavior: 'smooth' }); return; } const backgroundButton = event.target.closest && event.target.closest('[data-user-card-background]'); if (backgroundButton && actionCard && actionCard.contains(backgroundButton)) { event.preventDefault(); event.stopPropagation(); clearTimeout(userCardHideTimer); const source = String(backgroundButton.dataset.backgroundUrl || '').trim(); const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim(); openUserCardMediaViewer( source, username, actionCard, '', actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview, null, { kind: 'background', source } ); return; } const avatarButton = event.target.closest && event.target.closest('[data-user-card-avatar]'); if (avatarButton && actionCard && actionCard.contains(avatarButton)) { event.preventDefault(); event.stopPropagation(); clearTimeout(userCardHideTimer); const template = avatarButton.dataset.avatarTemplate || actionCard._ldpUserData?.avatar_template || ''; const username = avatarButton.dataset.avatarUsername || getUserCardUsername(userCardAnchor) || ''; const previewImage = avatarButton.querySelector('img'); const previewSource = previewImage && (previewImage.currentSrc || previewImage.src) || ''; openUserCardMediaViewer( template, username, actionCard, previewSource, actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview, actionCard._ldpUserData ); return; } const notificationOption = event.target.closest && event.target.closest('[data-user-notification-level]'); if (notificationOption && actionCard && actionCard.contains(notificationOption)) { event.preventDefault(); event.stopPropagation(); if (notificationOption.disabled || notificationOption.dataset.busy === '1') return; const level = notificationOption.dataset.userNotificationLevel; const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim(); const user = actionCard._ldpUserData || { username }; const anchor = userCardAnchor; const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') && String(actionCard._ldpUserData?.username || '') === username; if (!username || !anchor || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) return; clearTimeout(userCardHideTimer); if (level === 'ignore') { if (openReaderIgnoreDurationDialog(ctx, username, user)) closeUserCard(); else setUserCardActionStatus(actionCard, '当前无法打开忽略期限选择', true); return; } const menu = notificationOption.closest('.ldp-user-card-notification-menu'); const options = menu ? Array.from(menu.querySelectorAll('[data-user-notification-level]')) : []; options.forEach((option) => { option.dataset.busy = '1'; option.disabled = true; }); setUserCardActionStatus(actionCard, `正在设为${USER_NOTIFICATION_LEVEL_LABELS[level]}…`); try { await saveReaderUserNotificationLevel(username, user, level); if (!isCurrent()) return; options.forEach((option) => { const active = option.dataset.userNotificationLevel === level; option.classList.toggle('is-active', active); option.setAttribute('aria-checked', String(active)); }); const trigger = actionCard.querySelector('[data-user-card-action="notifications"]'); if (trigger) { const label = `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[level]}`; trigger.classList.toggle('is-active', level !== 'normal'); trigger.dataset.tooltip = label; trigger.setAttribute('aria-label', label); trigger.setAttribute('aria-expanded', 'false'); } if (menu) menu.hidden = true; setUserCardActionStatus(actionCard, `已设为${USER_NOTIFICATION_LEVEL_LABELS[level]}`); positionFallbackUserCardNotificationMenu(actionCard); } catch (error) { if (isCurrent()) { setUserCardActionStatus(actionCard, error && error.message ? error.message : '消息设置保存失败,请重试', true); } } finally { if (isCurrent()) { options.forEach((option) => { delete option.dataset.busy; option.disabled = false; }); } } return; } const button = event.target.closest && event.target.closest('[data-user-card-action]'); if (!button || !actionCard || !actionCard.contains(button) || button.disabled || button.dataset.busy === '1') return; event.preventDefault(); event.stopPropagation(); const action = button.dataset.userCardAction; const username = String(button.dataset.username || actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim(); const anchor = userCardAnchor; if (!action || !username || !anchor) return; if (actionCard === userCardFollowPreview) { if (!promoteUserCardFollowPreview()) return; userCardFollowPanel.hidden = true; } else if (userCardNavigationStack.length > 1) { restoreUserCardNavigationEntry(0); } clearTimeout(userCardHideTimer); if (action === 'notifications') { const menu = actionCard.querySelector('.ldp-user-card-notification-menu'); if (!menu) return; const opening = menu.hidden; menu.hidden = !opening; button.setAttribute('aria-expanded', String(opening)); setUserCardActionStatus(actionCard, ''); positionFallbackUserCardNotificationMenu(actionCard); return; } button.dataset.busy = '1'; button.setAttribute('aria-busy', 'true'); button.disabled = true; setUserCardActionStatus(actionCard, '正在打开…'); const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') && String(actionCard._ldpUserData?.username || '') === username; const outcome = await runDirectUserCardAction( action, username, actionCard._ldpUserData || { username }, ctx ); if (outcome.ok) { if (outcome.keepOpen && button.isConnected && isCurrent()) { delete button.dataset.busy; button.removeAttribute('aria-busy'); button.disabled = false; if (action === 'follow') { const label = outcome.followed ? '取消关注' : '关注'; button.classList.toggle('is-active', !!outcome.followed); button.dataset.tooltip = label; button.setAttribute('aria-label', label); setUserCardActionStatus(actionCard, outcome.followed ? '已关注' : '已取消关注'); const followerCount = Number(actionCard._ldpUserData && actionCard._ldpUserData.total_followers); const followerValue = actionCard.querySelector('[data-user-card-follow-type="followers"] strong'); if (followerValue && Number.isFinite(followerCount)) followerValue.textContent = followerCount.toLocaleString('zh-CN'); } return; } closeUserCard(); return; } if (!button.isConnected || !isCurrent()) return; delete button.dataset.busy; button.removeAttribute('aria-busy'); button.disabled = false; const label = button.getAttribute('aria-label') || '该功能'; const failureMessages = { 'route-unavailable': `未能打开“${label}”页面,请重试`, 'endorsement-unavailable': '当前页面暂时无法打开认可类别选择', 'follow-not-allowed': '该用户当前不允许被关注', 'action-failed': `执行“${label}”时发生错误,请重试`, }; const failureMessage = outcome.reason === 'action-failed' && outcome.error && outcome.error.message ? outcome.error.message : (failureMessages[outcome.reason] || `${label}当前不可用`); setUserCardActionStatus(actionCard, failureMessage, true); }; addTrackedEventListener(actionEventBindings, fallbackUserCard, 'click', onUserCardAction); addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'click', onUserCardAction); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'click', onUserCardAction); let userCardFollowPreviewActionIntent = null; const userCardFollowPreviewActionControl = (target) => target && target.closest && target.closest( '[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]' ); const armUserCardFollowPreviewAction = (event) => { const control = userCardFollowPreviewActionControl(event.target); userCardFollowPreviewActionIntent = control && userCardFollowPreview.contains(control) ? control : null; }; const armUserCardFollowPreviewKeyboardAction = (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; armUserCardFollowPreviewAction(event); }; addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'pointerdown', armUserCardFollowPreviewAction); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'pointercancel', () => { userCardFollowPreviewActionIntent = null; }); addTrackedEventListener(actionEventBindings, userCardFollowPreview, 'keydown', armUserCardFollowPreviewKeyboardAction); const onUserCardFollowSearch = (event) => { if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-follow-search input')) return; userCardFollowPanel.dataset.followPage = '0'; renderUserCardFollowList(userCardFollowPanel); positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard); }; addTrackedEventListener(actionEventBindings, userCardFollowPanel, 'input', onUserCardFollowSearch); const openUserCardFollowPreview = (link) => { const username = getUserCardUsername(link); if (!username || userCardFollowPanel.hidden || !userCardFollowPreviewHoverArmed) return; clearTimeout(userCardHideTimer); clearTimeout(userCardFollowPreviewShowTimer); userCardFollowPreviewShowTimer = 0; if (getUserCardUsername(userCardFollowPreviewAnchor) === username && userCardFollowPreview.classList.contains('open')) { userCardFollowPreviewAnchor = link; positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard); renderUserCardBreadcrumbs(); return; } userCardFollowPreviewAnchor = link; const token = ++userCardFollowPreviewToken; userCardFollowPreviewShowTimer = setTimeout(() => { userCardFollowPreviewShowTimer = 0; void showFallbackUserCard(link, () => token === userCardFollowPreviewToken && userCardFollowPreviewAnchor === link && !userCardFollowPanel.hidden && fallbackUserCardOpen(), renderUserCardBreadcrumbs, { card: userCardFollowPreview, positionCard: (preview) => positionUserCardFollowPreview(preview, userCardFollowPanel, fallbackUserCard), }); }, USER_CARD_SHOW_DELAY); }; const onUserCardMouseOver = (event) => { const link = eventPathClosest(event, USER_CARD_LINK_SEL); if (!link) return; const from = event.relatedTarget; if (from && typeof from.nodeType === 'number' && link.contains(from)) return; if (userCardFollowPanel.contains(link)) { openUserCardFollowPreview(link); return; } if (!userCardFollowPanel.hidden) { cancelUserCardOpening(); return; } const previousUsername = String(fallbackUserCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || ''); const nextUsername = getUserCardUsername(link); if (previousUsername && previousUsername !== nextUsername) { if (!userCardFollowPanel.contains(link)) closeUserCardFollowPanel(); cancelQueuedUserCardRequests(previousUsername, ctx.postRequestScheduler); } clearTimeout(userCardHideTimer); userCardHideTimer = 0; cancelUserCardOpening(); if (previousUsername === nextUsername && fallbackUserCardOpen()) { userCardAnchor = link; observeUserCardAnchor(link); positionFallbackUserCard(fallbackUserCard, link); return; } userCardAnchor = link; observeUserCardAnchor(link); const token = ++userCardToken; userCardPrefetchTimer = setTimeout(() => { userCardPrefetchTimer = 0; if (token !== userCardToken || userCardAnchor !== link) return; void fetchUserCard(nextUsername).catch(() => {}); }, USER_CARD_PREFETCH_DELAY); userCardShowTimer = setTimeout(() => { userCardShowTimer = 0; void showFallbackUserCard(link, () => token === userCardToken && userCardAnchor === link && userCardAnchorIsVisible(link), (user) => { userCardNavigationStack.length = 0; if (user && user.username) userCardNavigationStack.push(navigationEntryFromUser(user)); renderUserCardBreadcrumbs(); }); }, USER_CARD_SHOW_DELAY); }; const onUserCardMouseOut = (event) => { const link = eventPathClosest(event, USER_CARD_LINK_SEL); if (!link) return; const to = event.relatedTarget; if (userCardFollowPanel.contains(link)) { userCardFollowPreviewHoverArmed = true; const nextLink = to && typeof to.closest === 'function' ? to.closest(USER_CARD_LINK_SEL) : null; if (to && typeof to.nodeType === 'number' && (link.contains(to) || userCardFollowPreview.contains(to) || (nextLink && userCardFollowPanel.contains(nextLink)))) return; clearTimeout(userCardFollowPreviewShowTimer); userCardFollowPreviewShowTimer = 0; return; } if (to && typeof to.nodeType === 'number' && (link.contains(to) || fallbackUserCard.contains(to) || userCardFollowPanel.contains(to) || userCardFollowPreview.contains(to))) return; cancelUserCardOpening(); const nextLink = to && typeof to.closest === 'function' ? to.closest(USER_CARD_LINK_SEL) : null; if (nextLink && getUserCardUsername(nextLink) === getUserCardUsername(link)) { return; } scheduleCloseUserCard(); }; const onHostUserCardMouseOver = (event) => { const link = eventPathClosest(event, USER_CARD_LINK_SEL); if (!link || userCardEventRoot.contains(link)) return; onUserCardMouseOver(event); }; const onHostUserCardMouseOut = (event) => { const link = eventPathClosest(event, USER_CARD_LINK_SEL); if (!link || userCardEventRoot.contains(link)) return; onUserCardMouseOut(event); }; addTrackedEventListener(actionEventBindings, userCardEventRoot, 'mouseover', onUserCardMouseOver, true); addTrackedEventListener(actionEventBindings, userCardEventRoot, 'mouseout', onUserCardMouseOut, true); addTrackedEventListener(actionEventBindings, document, 'mouseover', onHostUserCardMouseOver, true); addTrackedEventListener(actionEventBindings, document, 'mouseout', onHostUserCardMouseOut, true); const onTopicNotificationChange = async (event) => { const select = event.target.closest('.ldp-topic-notification-select'); if (!select || !modal.contains(select)) return; const nextLevel = +select.value; if (!Number.isInteger(nextLevel) || nextLevel < 0 || nextLevel > 3) return; const previousLevel = Number.isInteger(ctx.topicNotificationLevel) ? ctx.topicNotificationLevel : topicNotificationLevel(ctx.topicData); if (nextLevel === previousLevel) return; ctx.topicNotificationLevel = nextLevel; syncTopicActionControls(ctx, { notificationBusy: true }); try { await apiSend(`${BASE}/t/${ctx.topicId}/notifications`, 'POST', { notification_level: nextLevel, }); if (ctx.topicData) { ctx.topicData.notification_level = nextLevel; if (ctx.topicData.details) ctx.topicData.details.notification_level = nextLevel; setCachedTopicData(ctx.topicId, ctx.topicData); } } catch (error) { ctx.topicNotificationLevel = previousLevel; alert('通知设置失败:' + error.message); } finally { syncTopicActionControls(ctx); } }; addTrackedEventListener(actionEventBindings, modal, 'change', onTopicNotificationChange); const onReaderPollChange = async (event) => { const input = event.target.closest('.ldp-reader-poll input[data-poll-option]'); if (!input || !modal.contains(input) || input.disabled) return; const container = input.closest('.ldp-reader-poll'); const post = input.closest('.ldp-post'); if (!container || !post) return; const name = container.dataset.ldpPollName || 'poll'; const postData = post._ldpPostData || {}; const poll = (postData.polls || []).find((item) => pollName(item) === name); if (!poll) return; if (input.type === 'radio') { await updateReaderPollVote(post, ctx, name, [input.value]); return; } const selected = Array.from(container.querySelectorAll('input[data-poll-option]:checked')).map((item) => item.value); const { min, max } = pollChoiceLimits(poll); const submit = container.querySelector('[data-poll-action="submit"]'); if (submit) { submit.disabled = selected.length < min || selected.length > max; submit.textContent = pollSavedVotes(postData, poll).length ? '更新投票' : '提交投票'; } }; addTrackedEventListener(actionEventBindings, modal, 'change', onReaderPollChange); const onPostVotingCommentSubmit = async (event) => { const form = event.target.closest('.ldp-pv-comment-form'); if (!form || !modal.contains(form)) return; event.preventDefault(); const post = form.closest('.ldp-post'); const input = form.querySelector('.ldp-pv-comment-input'); const raw = String(input && input.value || '').trim(); if (!post || !raw) return; const postId = post.dataset.postId; await runReaderActionOnce(ctx, `post:${postId}:pv-comment-create`, async () => { setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', true); try { const data = await apiSend(`${BASE}/post_voting/comments`, 'POST', { post_id: postId, raw }); const existing = readerPostVotingComments(postId, ctx); const comments = new Map(existing.map((comment) => [String(comment.id), comment])); const comment = data.comment || data; if (comment && comment.id) comments.set(String(comment.id), comment); readerPostCopies(postId, ctx).forEach((copy) => { const root = copy.querySelector(':scope > .ldp-pv-comments'); if (root) root.dataset.expanded = '1'; }); syncPostVotingComments(postId, ctx, Array.from(comments.values())); } catch (error) { alert('发送评论失败:' + error.message); } finally { setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', false); } }); }; addTrackedEventListener(actionEventBindings, modal, 'submit', onPostVotingCommentSubmit); const showReaderAvatarPreview = (avatarPreview) => { const post = avatarPreview.closest('.ldp-post'); const postData = post && post._ldpPostData || {}; const username = String(postData.username || avatarPreview.dataset.userCard || '').trim(); const avatarImage = avatarPreview.querySelector('.ldp-avatar'); const template = String(postData.avatar_template || avatarImage?.dataset.ldpAvatarOriginalTemplate || avatarImage?.dataset.ldpAvatarTemplate || '').trim(); const previewSource = avatarImage && (avatarImage.currentSrc || avatarImage.src) || ''; const openPreviewFromCard = (user) => { if (!fallbackUserCardOpen() || getUserCardUsername(userCardAnchor) !== username) return; openUserCardMediaViewer( template, username, fallbackUserCard, previewSource, closeUserCard, user || postData ); }; clearTimeout(userCardHideTimer); clearTimeout(userCardShowTimer); userCardShowTimer = 0; const displayedUser = fallbackUserCard._ldpUserData; const cardShownForUser = fallbackUserCardOpen() && String(displayedUser?.username || '') === username; if (cardShownForUser) { openPreviewFromCard(displayedUser); return; } userCardAnchor = avatarPreview; observeUserCardAnchor(avatarPreview); const token = ++userCardToken; void showFallbackUserCard( avatarPreview, () => token === userCardToken && userCardAnchor === avatarPreview && userCardAnchorIsVisible(avatarPreview), (user) => { userCardNavigationStack.length = 0; if (user && user.username) userCardNavigationStack.push(navigationEntryFromUser(user)); renderUserCardBreadcrumbs(); openPreviewFromCard(user); } ); }; const onPortalAvatarPreviewClick = (event) => { const avatarPreview = eventPathClosest(event, '[data-user-avatar-preview]'); if (!avatarPreview || modal.contains(avatarPreview)) return; event.preventDefault(); event.stopPropagation(); showReaderAvatarPreview(avatarPreview); }; addTrackedEventListener(actionEventBindings, userCardEventRoot, 'click', onPortalAvatarPreviewClick, true); const preserveOnlyOpPostExpansion = (post) => { const preserve = ctx.onlyOp && !post._ldpPostData?.hidden && String(post.dataset.username || '') === String(ctx.op || ''); if (preserve) keepOnlyOpPostExpanded(post, ctx); return preserve; }; const onModalClick = async (e) => { const imageCandidate = eventPathClosest(e, '.ldp-content img') || eventPathClosest(e, '.ldp-content a.lightbox')?.querySelector('img'); if (imageCandidate && !reactionEmojiImageSources) { e.preventDefault(); e.stopPropagation(); await hydrateReactionRegistry(); } const quotedImg = lightboxQuotedImageFromEvent(e); if (quotedImg) { e.preventDefault(); e.stopPropagation(); hideImageQuoteToolbar(); await openQuotedImageLightbox(quotedImg, ctx); return; } const img = lightboxImageFromEvent(e); if (img) { e.preventDefault(); e.stopPropagation(); hideImageQuoteToolbar(); openLightbox(img, ctx); return; } const avatarPreview = eventPathClosest(e, '[data-user-avatar-preview]'); if (avatarPreview) { e.preventDefault(); e.stopPropagation(); showReaderAvatarPreview(avatarPreview); return; } const codeAction = eventPathClosest(e, '[data-reader-code-action]'); if (codeAction) { e.preventDefault(); e.stopPropagation(); const pre = codeAction.closest('.ldp-code-block')?.querySelector(':scope > pre'); if (codeAction.dataset.readerCodeAction === 'copy') await copyReaderCode(pre, codeAction); else openReaderCodePreview(pre, modal); return; } const hostLink = eventPathClosest(e, 'a[href]'); if (openEmbeddedReaderLinkInHost(e, hostLink, ctx.readerWorkspace)) return; const githubOnebox = eventPathClosest( e, '.ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])' ); if (githubOnebox) { const selection = window.getSelection && window.getSelection(); if (selection && !selection.isCollapsed && String(selection).trim()) return; const targetLink = hostLink || githubOnebox.querySelector('.onebox-body h3 a[href],header.source a[href],a[href]'); const targetUrl = String(targetLink?.href || githubOnebox.dataset.oneboxSrc || '').trim(); if (/^https?:\/\//i.test(targetUrl)) { e.preventDefault(); e.stopPropagation(); const openInCurrentTab = targetLink?.target === '_self' && !e.ctrlKey && !e.metaKey && !e.shiftKey; if (openInCurrentTab) window.location.assign(targetUrl); else window.open(targetUrl, '_blank', 'noopener'); return; } } const quoteAction = e.target.closest('.ldp-quote-toggle,.ldp-quote-collapse-bottom'); if (quoteAction) { e.preventDefault(); const quote = quoteAction.closest('.ldp-post-quote'); const quoteBody = quote && quote.querySelector(':scope > blockquote'); const quoteToggle = quote && quote.querySelector(':scope > .ldp-quote-title .ldp-quote-toggle'); if (!quote || !quoteBody || !quoteToggle) return; if (quote.dataset.ldpQuoteExpanded === '1') { preserveReaderQuotePosition(quote, ctx, () => { quote.querySelector(':scope > .ldp-quote-collapse-bottom')?.remove(); setPostCookedContent(quoteBody, quote._ldpQuoteExcerptHtml || ''); quote.dataset.ldpQuoteExpanded = '0'; quote.classList.remove('ldp-quote-expanded'); quoteToggle.setAttribute('aria-expanded', 'false'); quoteToggle.setAttribute('aria-label', '展开完整引用'); }); syncReaderQuoteLayout(quote, ctx); return; } quoteToggle.disabled = true; quoteToggle.setAttribute('aria-busy', 'true'); try { const fullPost = await loadReaderQuotedPost(quote, ctx); if (!fullPost || fullPost.cooked == null) throw new Error('quoted post unavailable'); preserveReaderQuotePosition(quote, ctx, () => { setPostCookedContent(quoteBody, fullPost.cooked); quote.dataset.ldpQuoteExpanded = '1'; quote.classList.add('ldp-quote-expanded'); quoteToggle.setAttribute('aria-expanded', 'true'); quoteToggle.setAttribute('aria-label', '恢复引用摘录'); highlightReaderExpandedQuote(quote); const bottomCollapse = document.createElement('button'); bottomCollapse.type = 'button'; bottomCollapse.className = 'ldp-quote-collapse-bottom'; bottomCollapse.setAttribute('aria-label', '恢复引用摘录'); bottomCollapse.innerHTML = icon('chevronUp'); quote.append(bottomCollapse); }); syncReaderQuoteLayout(quote, ctx); } catch (error) { quoteToggle.setAttribute('aria-label', '完整引用加载失败,点击重试'); } finally { quoteToggle.disabled = false; quoteToggle.removeAttribute('aria-busy'); } return; } const topicVote = e.target.closest('[data-topic-vote]'); if (topicVote) { e.preventDefault(); if (!ME_USERNAME) { alert('登录后才能为主题投票。'); return; } await runReaderActionOnce(ctx, `topic:${ctx.topicId}:vote`, async () => { const voted = !!(ctx.topicData && ctx.topicData.user_voted); modal.querySelectorAll('[data-topic-vote]').forEach((button) => { button.disabled = true; }); try { const data = await apiSend(`${BASE}/voting/${voted ? 'unvote' : 'vote'}.json`, 'POST', { topic_id: ctx.topicId }); ctx.topicData.user_voted = !voted; ctx.topicData.vote_count = Math.max(0, +(data.vote_count ?? data.votes ?? ctx.topicData.vote_count) || 0); if (data.can_vote != null) ctx.topicData.can_vote = data.can_vote; setCachedTopicData(ctx.topicId, ctx.topicData); if (ctx.renderTopicShell) ctx.renderTopicShell(ctx.topicData); } catch (error) { alert('主题投票失败:' + error.message); } finally { modal.querySelectorAll('[data-topic-vote]').forEach((button) => { button.disabled = ctx.topicData && ctx.topicData.can_vote === false && !ctx.topicData.user_voted; }); } }); return; } const nestedRailToggle = e.target.closest('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle'); if (nestedRailToggle) { e.preventDefault(); const post = nestedRailToggle.closest('.ldp-post'); if (preserveOnlyOpPostExpansion(post)) return; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const parentContent = post.querySelector(':scope > .ldp-content'); const parentReactions = post.querySelector(':scope > .ldp-reactions'); const parentBodyEnd = parentReactions && parentReactions.getClientRects().length ? parentReactions : (parentContent || post); const expandedContentRect = (parentContent || parentBodyEnd).getBoundingClientRect(); const expandedBodyEndRect = parentBodyEnd.getBoundingClientRect(); const parentBodyEndVisible = expandedBodyEndRect.bottom >= rootRect.top && expandedBodyEndRect.bottom <= rootRect.bottom; setChildRepliesCollapsed(post, true); const collapsedContentRect = (parentContent || post).getBoundingClientRect(); if (parentBodyEndVisible) { const topOffset = collapsedContentRect.top - expandedContentRect.top; if (Math.abs(topOffset) >= 1) { const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop += topOffset; markStreamInternalScroll(ctx, previousScrollTop); scheduleStreamWindowSync(ctx); } return; } const parentPostNumber = streamPostNumber(post); const programmaticTarget = beginStreamProgrammaticTarget(ctx, parentPostNumber, { visualNode: parentBodyEnd, alignBottomToCenter: true, }); animateStreamProgrammaticTarget(ctx, programmaticTarget); return; } const nestedToggle = e.target.closest('.ldp-nested-toggle'); if (nestedToggle) { const post = nestedToggle.closest('.ldp-post'); if (preserveOnlyOpPostExpansion(post)) return; const willCollapse = !post.classList.contains('ldp-nested-collapsed'); if (willCollapse) delete post.dataset.ldpJumpExpanded; setNestedReplyCollapsed(post, willCollapse); if (willCollapse) floorPreview.hide(post); return; } const rowToggleTarget = e.target.closest('.ldp-post.ldp-nested-collapsed'); if (rowToggleTarget && !e.target.closest('a,button,input,textarea,select,label')) { setNestedReplyCollapsed(rowToggleTarget, false); return; } const collapseBtn = e.target.closest('.ldp-collapse-replies'); if (collapseBtn) { const post = collapseBtn.closest('.ldp-post'); if (preserveOnlyOpPostExpansion(post)) return; setChildRepliesCollapsed(post, !post.classList.contains('ldp-children-collapsed')); return; } // 楼中楼分页按钮 const pageBtn = e.target.closest('.ldp-sub-page-prev, .ldp-sub-page-next'); if (pageBtn) { if (pageBtn.disabled) return; const post = pageBtn.closest('.ldp-post'); const postNumber = +post.dataset.postNumber; const direction = pageBtn.classList.contains('ldp-sub-page-next') ? 1 : -1; if (direction > 0) { const state = ctx.subReplyState.get(postNumber); const pageSize = SUB_REPLY_PAGE_SIZE; const targetPage = +(state && state.pageIndex || 0) + 1; const requiredCount = Math.min(+(state && state.totalCount || 0), (targetPage + 1) * pageSize); if (state && +(state.fetchedReplyCount || 0) < requiredCount && state.hasMore !== false) { await ctx.repliesIO.loadNextPage(post, requiredCount); } const updatedState = ctx.subReplyState.get(postNumber); if (!updatedState || targetPage * pageSize >= updatedState.all.length) return; } renderSubReplyBatch(postNumber, ctx, direction, post); return; } const quoteJump = e.target.closest('.ldp-quote-jump'); const preview = quoteJump && quoteJump.closest('.ldp-floor-preview'); const post = e.target.closest('.ldp-post') || (preview && preview._ldpRelatedPost); if (!post) return; const postId = post.dataset.postId, postNumber = +post.dataset.postNumber; if (quoteJump) { const targetTopicId = +quoteJump.dataset.targetTopicId || 0; if (targetTopicId && targetTopicId !== +ctx.topicId) { e.preventDefault(); const targetPostNumber = +quoteJump.dataset.targetPostNumber || 1; const quoteHighlight = quoteHighlightFromJump(quoteJump, ctx); openModal(targetTopicId, { targetPostNumber, quoteHighlight }); return; } } const reactionPicker = e.target.closest('[data-reaction-picker]'); if (reactionPicker) { hydrateReactionRegistry(); const reactionsRoot = reactionPicker.closest('.ldp-reactions'); const picker = reactionsRoot?.querySelector('.ldp-reaction-picker'); const open = picker && picker.hidden; setReactionPickerExpanded(reactionsRoot, open); return; } const reactionButton = e.target.closest('[data-reaction]'); if (reactionButton) { const reaction = reactionButton.dataset.reaction; const reactionsRoot = reactionButton.closest('.ldp-reactions'); const picker = reactionButton.closest('.ldp-reaction-picker'); if (picker) { setReactionPickerExpanded(reactionsRoot, false); } await toggleReaderPostReaction(post, ctx, reaction, null, (error) => alert('回应失败:' + error.message)); return; } const pvVote = e.target.closest('[data-pv-vote]'); if (pvVote) { const direction = pvVote.dataset.pvVote; const postData = post._ldpPostData || {}; const remove = postData.post_voting_user_voted_direction === direction; await runReaderActionOnce(ctx, `post:${postId}:pv-vote`, async () => { setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', true); try { const data = await apiSend(`${BASE}/post_voting/vote`, remove ? 'DELETE' : 'POST', remove ? { post_id: postId } : { post_id: postId, direction }); const fresh = data.post || data; if (fresh.post_voting_vote_count == null && data.vote_count != null) fresh.post_voting_vote_count = data.vote_count; if (fresh.post_voting_user_voted_direction == null) fresh.post_voting_user_voted_direction = remove ? null : direction; syncSpecialPostData(post, ctx, fresh); } catch (error) { alert('回答投票失败:' + error.message); readerPostCopies(postId, ctx).forEach((copy) => renderPostVoting(copy, ctx)); } finally { setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', false); } }); return; } const commentsToggle = e.target.closest('.ldp-pv-comments-toggle'); const commentsMore = !commentsToggle && e.target.closest('.ldp-pv-comments-more'); const commentsAction = commentsToggle || commentsMore; if (commentsAction) { const root = commentsToggle && commentsToggle.closest('.ldp-pv-comments'); const open = !commentsToggle || root.dataset.expanded !== '1'; if (commentsToggle) root.dataset.expanded = open ? '1' : '0'; const loadComments = commentsMore || (open && !(post._ldpPostData._ldpPvComments || post._ldpPostData.comments || []).length && +post._ldpPostData.comments_count > 0); if (loadComments) { commentsAction.disabled = true; try { await fetchPostVotingComments(post, ctx); } catch (error) { alert('加载评论失败:' + error.message); } } renderPostVoting(post, ctx); return; } const pvCommentVote = e.target.closest('[data-pv-comment-vote]'); if (pvCommentVote) { const commentNode = pvCommentVote.closest('.ldp-pv-comment'); const commentId = commentNode && commentNode.dataset.commentId; const remove = pvCommentVote.classList.contains('on'); await runReaderActionOnce(ctx, `post-voting-comment:${commentId}:vote`, async () => { setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', true); try { const data = await apiSend( `${BASE}/post_voting/vote/comment`, remove ? 'DELETE' : 'POST', { comment_id: commentId }, null, { invalidateTags: [`post:${postId}`] } ); const existing = readerPostVotingComments(postId, ctx); const comments = existing.slice(); const comment = comments.find((item) => String(item.id) === String(commentId)); if (comment) { comment.user_voted = !remove; comment.post_voting_vote_count = +(data.vote_count ?? comment.post_voting_vote_count) || 0; } syncPostVotingComments(postId, ctx, comments); } catch (error) { alert('评论投票失败:' + error.message); } finally { setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', false); } }); return; } const eventAction = e.target.closest('[data-event-status]'); if (eventAction) { const status = eventAction.dataset.eventStatus; const eventData = post._ldpPostData && post._ldpPostData.event || {}; const invitee = eventData.watching_invitee || {}; await runReaderActionOnce(ctx, `post:${postId}:event-attendance`, async () => { setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', true); try { const isUpdate = !!invitee.id; const url = isUpdate ? `${BASE}/discourse-post-event/events/${postId}/invitees/${invitee.id}` : `${BASE}/discourse-post-event/events/${postId}/invitees`; const params = { 'invitee[status]': status, 'invitee[recurring]': false }; if (!isUpdate && ME_CURRENT_USER && ME_CURRENT_USER.id) params['invitee[user_id]'] = ME_CURRENT_USER.id; const data = await apiSend(url, isUpdate ? 'PUT' : 'POST', params, null, { invalidateTags: [`post:${postId}`], }); const responseInvitee = data.invitee || data; eventData.watching_invitee = Object.assign({}, invitee, responseInvitee, { status }); if (responseInvitee.meta && responseInvitee.meta.event_stats) eventData.stats = responseInvitee.meta.event_stats; syncSpecialPostData(post, ctx, { event: eventData }); } catch (error) { alert('活动报名失败:' + error.message); readerPostCopies(postId, ctx).forEach(renderPostEvent); } finally { setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', false); } }); return; } const pollAction = e.target.closest('[data-poll-action]'); if (pollAction) { e.preventDefault(); const container = pollAction.closest('.ldp-reader-poll'); const name = container && (container.dataset.ldpPollName || 'poll'); if (!container || !name) return; const action = pollAction.dataset.pollAction; if (action === 'toggle-results') { const showResults = container.dataset.ldpPollShowResults !== '1'; readerPostCopies(postId, ctx).forEach((copy) => renderPostPolls(copy, ctx, { name, showResults })); return; } if (action === 'remove') { await updateReaderPollVote(post, ctx, name); return; } if (action === 'submit') { const selected = Array.from(container.querySelectorAll('input[data-poll-option]:checked')).map((item) => item.value); const postData = post._ldpPostData || {}; const poll = (postData.polls || []).find((item) => pollName(item) === name); const { min, max } = pollChoiceLimits(poll); if (!poll || selected.length < min || selected.length > max) return; await updateReaderPollVote(post, ctx, name, selected); return; } } const readerAction = e.target.closest('[data-reader-action]'); if (readerAction) { e.preventDefault(); const action = readerAction.dataset.readerAction; if (action === 'report') openReaderReportDialog(post, ctx); else if (action === 'assign-post') openReaderAssignDialog(post, ctx, 'Post'); else if (action === 'assign-topic') openReaderAssignDialog(post, ctx, 'Topic'); return; } const postAction = e.target.closest('[data-post-action]'); if (postAction) { e.preventDefault(); const action = postAction.dataset.postAction; if (action === 'share') { await shareReaderLink(ctx, post); return; } if (action === 'bookmark' && !postAction.disabled) { await toggleReaderBookmark(post, ctx, 'Post'); return; } if (action === 'edit' && !postAction.disabled) { await runReaderActionOnce( ctx, `post:${postId}:edit-composer`, () => tryOpenNativeEditComposer(post, ctx) ); return; } if (action === 'delete' && !postAction.disabled) { postAction.disabled = true; try { await deleteReaderPost(post, ctx); } catch (error) { alert('删除失败:' + error.message); } finally { if (postAction.isConnected) postAction.disabled = false; } return; } if (action === 'admin' && !postAction.disabled) { openNativeAdminPostMenu(post, postAction, ctx); return; } } const topicAction = e.target.closest('[data-topic-action]'); if (topicAction) { const action = topicAction.dataset.topicAction; if (action === 'shared-issue' && !topicAction.disabled) { if (!ME_USERNAME) await ensureMe(); if (!ME_USERNAME) { alert('登录后才能使用“俺也一样”。'); return; } const firstPostNode = topicAction.closest('.ldp-post'); const sharedIssue = topicSharedIssueState(firstPostNode && firstPostNode._ldpPostData, ctx); if (!sharedIssue.visible || sharedIssue.isAuthor) return; await runReaderActionOnce(ctx, `topic:${ctx.topicId}:shared-issue`, async () => { ctx.topicSharedIssueBusy = true; syncTopicActionControls(ctx); try { const data = await apiSend(`${BASE}/solution/shared_issue`, 'POST', { topic_id: ctx.topicId }); ctx.topicData.shared_issue_count = Math.max(0, Number(data && data.count) || 0); ctx.topicData.user_created_shared_issue = !!(data && data.user_created_shared_issue); if (ctx.topicData.user_created_shared_issue) { const currentLevel = Number.isInteger(ctx.topicNotificationLevel) ? ctx.topicNotificationLevel : topicNotificationLevel(ctx.topicData); if (currentLevel < 2) { ctx.topicNotificationLevel = 2; ctx.topicData.notification_level = 2; if (ctx.topicData.details) ctx.topicData.details.notification_level = 2; } } setCachedTopicData(ctx.topicId, ctx.topicData); } catch (error) { if (Number(error && error.status) === 403) { SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.add(Number(ctx.topicId)); } else { alert('“俺也一样”操作失败:' + error.message); } } finally { ctx.topicSharedIssueBusy = false; syncTopicActionControls(ctx); } }); return; } if (action === 'share') { await shareReaderLink(ctx); return; } if (action === 'bookmark') { if (typeof ctx.toggleTopicBookmark === 'function') await ctx.toggleTopicBookmark(); return; } } const jumpBtn = e.target.closest('.ldp-jump-parent, .ldp-jump-back, .ldp-jump-self, .ldp-jump-preview-self, .ldp-quote-jump'); if (jumpBtn) { const targetPostNumber = +jumpBtn.dataset.targetPostNumber; const quoteHighlight = quoteJump ? quoteHighlightFromJump(jumpBtn, ctx) : null; jumpBtn.blur(); if (!targetPostNumber) return; if (jumpBtn.classList.contains('ldp-jump-parent') && post && post.classList.contains('ldp-nested-collapsed')) { if (jumpBtn.getAttribute('aria-busy') === 'true') return; clearStreamViewportAnchor(ctx); ctx.streamScrollDirection = 0; ctx.scrollRoot.classList.add('ldp-floor-preview-positioning'); const collapsedBottom = post.getBoundingClientRect().bottom; jumpBtn.setAttribute('aria-busy', 'true'); jumpBtn.setAttribute('aria-label', `正在准备父楼层 #${targetPostNumber}`); try { await new Promise((resolve) => { requestAnimationFrame(resolve); }); if (!jumpBtn.isConnected || !post.classList.contains('ldp-nested-collapsed')) return; await floorPreview.show(jumpBtn, { collapsedBottom, allowCollapsed: true, beforePlace: () => { setNestedReplyCollapsed(post, false); hydratePostContent(post, ctx); }, }); } finally { jumpBtn.removeAttribute('aria-busy'); if (jumpBtn.isConnected && post.classList.contains('ldp-nested-collapsed')) { jumpBtn.setAttribute('aria-label', `展开楼层并预览父楼层 #${targetPostNumber}`); } requestAnimationFrame(() => ctx.scrollRoot.classList.remove('ldp-floor-preview-positioning')); } return; } let prepareTarget = null; if (jumpBtn.classList.contains('ldp-jump-self')) { const parentPostNumber = +jumpBtn.dataset.parentPostNumber || +(post && post.dataset.replyToPostNumber) || 0; prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx); } else if (jumpBtn.classList.contains('ldp-jump-parent')) { prepareTarget = () => showJumpBackLink(targetPostNumber, postNumber, ctx); } else { clearJumpBackLinks(ctx); if (quoteJump && quoteHighlight) { prepareTarget = () => applyReaderQuoteHighlight( targetPostNumber, ctx, quoteHighlight.text, { source: quoteHighlight.source }, ); } else if (!quoteJump && !jumpBtn.classList.contains('ldp-jump-preview-self')) { prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, postNumber, ctx); } } const jumped = await ctx.timeline.jumpTo(targetPostNumber, { prepareTarget, alignNearest: !!quoteHighlight, }); if (!jumped && quoteHighlight) clearReaderQuoteHighlight(ctx); return; } const boostDeleteAction = e.target.closest('.ldp-boost-delete-action'); const boostItemAction = boostDeleteAction || e.target.closest('.ldp-boost-report-action'); if (boostItemAction && !boostItemAction.disabled) { e.preventDefault(); const boostBubble = boostItemAction.closest('.ldp-boost-bubble'); const boostId = boostBubble && boostBubble.dataset.boostId; const report = !boostDeleteAction; boostItemAction.disabled = true; try { await runReaderActionOnce( ctx, `boost:${boostId}:${report ? 'report-dialog' : 'delete'}`, () => report ? openReaderBoostReportDialog(boostBubble, ctx) : deleteReaderBoost(post, boostBubble, ctx) ); } catch (error) { if (report) showSelectionToast(error && error.message ? error.message : '举报 Boost 失败,请重试'); else alert('删除 Boost 失败:' + error.message); } finally { if (boostItemAction.isConnected) boostItemAction.disabled = false; } return; } const boostCopyAction = e.target.closest('.ldp-boost-copy-action'); const boostMenuAction = boostCopyAction || e.target.closest('.ldp-boostbtn'); if (boostMenuAction) { if (boostCopyAction) e.preventDefault(); const initialRaw = boostCopyAction ? applyBoostCopyRule(boostBubblePlainText(boostCopyAction.closest('.ldp-boost-bubble'))) : ''; renderNativeBoostMenu(post, boostMenuAction, ctx, initialRaw); return; } const likeBtn = e.target.closest('.ldp-like'); if (likeBtn && !likeBtn.disabled) { await runReaderActionOnce(ctx, `post:${postId}:like`, async () => { const current = readerPostCopies(postId, ctx).find((copy) => copy.querySelector('.ldp-like')) || post; const currentButton = current.querySelector('.ldp-like'); await toggleReaderPostLike(postId, ctx, currentButton, { setBusy: (busy) => setReaderPostControlsBusy(postId, ctx, '.ldp-like', busy), postData: post._ldpPostData, onError: (error) => alert('操作失败:' + error.message), }); }); return; } const replyBtn = e.target.closest('.ldp-replybtn'); if (replyBtn) { await runReaderActionOnce( ctx, `post:${postId}:reply-composer`, () => tryOpenNativeReplyComposer(post, ctx) ); } }; addTrackedEventListener(actionEventBindings, modal, 'click', onModalClick); return () => { const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu'); if (boostMenu && typeof boostMenu._ldpClose === 'function') boostMenu._ldpClose(); closeUserCard(); if (ctx.closeUserCard === closeUserCard) ctx.closeUserCard = null; if (ctx.closeUserCardLayer === closeUserCardLayer) ctx.closeUserCardLayer = null; if (ctx.closeTransientActions === closeTransientActions) ctx.closeTransientActions = null; clearActivePostActions(); removeTrackedEventListeners(actionEventBindings); if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame); selectionToolbarFrame = 0; if (imageQuotePositionFrame) cancelAnimationFrame(imageQuotePositionFrame); imageQuotePositionFrame = 0; clearTimeout(imageQuoteHideTimer); clearImageQuoteCycleTimers(); reactionHoverOpenTimers.forEach((timer) => clearTimeout(timer)); reactionHoverCloseTimers.forEach((timer) => clearTimeout(timer)); reactionVisibilityObserver?.disconnect(); reactionVisibilityObserver = null; reactionHoverOpenTimers.clear(); reactionHoverCloseTimers.clear(); selectionToolbar.remove(); imageQuoteToolbar.remove(); readerPortalQuery('.ldp-selection-toast')?.remove(); floorPreview.destroy(); }; } /* ============ 11. 楼中楼补全(分批渲染 + 节流 + 停顿检测) ============ */ function directRepliesForParent(replies, postNumber) { const parent = +postNumber; const posts = Array.isArray(replies) ? replies : postsFromPayload(replies); return posts.filter((reply) => { const rawReplyTo = reply && reply.reply_to_post_number; const replyTo = +(rawReplyTo || 0); // /posts/:id/replies.json is already scoped to this post. LinuxDo can // omit reply_to_post_number here, but an explicit different parent is a // descendant and must stay out of this two-level reply component. return !replyTo || replyTo === parent; }); } function mergeDirectReplies(...groups) { const merged = new Map(); groups.flat().forEach((reply) => { if (!reply) return; const key = +(reply.post_number || 0) || String(reply.id || ''); if (key) merged.set(key, reply); }); return Array.from(merged.values()).sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0)); } function replyPageCursor(posts) { return posts.reduce( (latest, reply) => Math.max(latest, +(reply && reply.post_number || 0)), 0, ); } function createRepliesIO(ctx) { const fetchedNodes = new WeakSet(); const hoverTimers = new WeakMap(); const hoverTimerNodes = new Set(); const emptyRetryCounts = new WeakMap(); const transientRetryCounts = new WeakMap(); const retryTimers = new Map(); const replyPagesByKey = new Map(); const pendingReplyPageStarts = new Map(); const completeDirectReplyPostIds = new Set(); const pendingPrefetchNodes = new Set(); const visiblePrefetchNodes = new Set(); let nestedPrefetchRunning = false; let nestedPrefetchStopped = false; let nestedPrefetchResumeTimer = 0; const sessionActive = () => !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active'); const assertSessionActive = () => { if (!sessionActive()) throw new DOMException('Reader session ended', 'AbortError'); }; const replyRequestKey = (postId, after = 0) => `replies:${postId}:${Math.max(0, +after || 0)}`; const cancelNestedPrefetch = (post, cancelQueued = true) => { if (!post) return; if (hoverTimers.has(post)) clearTimeout(hoverTimers.get(post)); hoverTimers.delete(post); hoverTimerNodes.delete(post); if (retryTimers.has(post)) clearTimeout(retryTimers.get(post)); retryTimers.delete(post); visiblePrefetchNodes.delete(post); transientRetryCounts.delete(post); pendingPrefetchNodes.delete(post); if (cancelQueued) ctx.postRequestScheduler.cancelQueued(replyRequestKey(post.dataset.postId, 0)); }; function scheduleNestedPrefetchDrain() { if (nestedPrefetchStopped || nestedPrefetchResumeTimer || !pendingPrefetchNodes.size) return; const scrollAge = Date.now() - +(ctx.streamLastScrollAt || 0); nestedPrefetchResumeTimer = setTimeout(() => { nestedPrefetchResumeTimer = 0; if (sessionActive()) drainNestedPrefetchQueue(); }, Math.max(120, 240 - scrollAge)); } async function drainNestedPrefetchQueue() { if (nestedPrefetchRunning || nestedPrefetchStopped) return; if (streamScrollIsActive(ctx, 220)) { scheduleNestedPrefetchDrain(); return; } nestedPrefetchRunning = true; try { while (!nestedPrefetchStopped && sessionActive() && pendingPrefetchNodes.size) { if (streamScrollIsActive(ctx, 220)) { scheduleNestedPrefetchDrain(); break; } const candidates = Array.from(pendingPrefetchNodes) .filter((node) => node && node.isConnected); let post = candidates[0] || null; if (candidates.length > 1) { const rootRect = ctx.scrollRoot.getBoundingClientRect(); const rootCenter = (rootRect.top + rootRect.bottom) / 2; post = candidates .map((node, order) => { const rect = node.getBoundingClientRect(); return { node, order, distance: Math.abs((rect.top + rect.bottom) / 2 - rootCenter), }; }) .sort((a, b) => a.distance - b.distance || a.order - b.order)[0].node; } if (!post) { pendingPrefetchNodes.clear(); break; } pendingPrefetchNodes.delete(post); try { const loaded = await loadRepliesFor(post, POST_REQUEST_PRIORITY.nested); if (loaded) io.unobserve(post); transientRetryCounts.delete(post); } catch (error) { const status = Number(error && error.status || 0); const transient = !!error && ( error.name === 'AbortError' || error.name === 'TypeError' || status === 408 || status === 429 || status >= 500 ); if (transient) scheduleNestedPrefetchRetry(post, error); } } } finally { nestedPrefetchRunning = false; if (!nestedPrefetchStopped && pendingPrefetchNodes.size) scheduleNestedPrefetchDrain(); } } const enqueueNestedPrefetch = (post) => { if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected || fetchedNodes.has(post)) return; pendingPrefetchNodes.add(post); drainNestedPrefetchQueue(); }; function scheduleNestedPrefetchRetry(post, cause = null) { if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected || !visiblePrefetchNodes.has(post) || fetchedNodes.has(post) || retryTimers.has(post)) return; const retryCount = +(transientRetryCounts.get(post) || 0) + 1; transientRetryCounts.set(post, retryCount); let requestState = null; try { requestState = ctx.postRequestScheduler.snapshot(); } catch (error) {} const controllerWait = Math.max( Number(requestState && requestState.cooldownRemaining) || 0, Number(requestState && requestState.nextPermitDelay) || 0, Math.max(0, Number(cause && cause.retryAt || 0) - Date.now()) ); const delay = controllerWait > 0 ? Math.min(60000, controllerWait + 80) : Math.min(15000, 500 * Math.pow(2, Math.min(5, retryCount - 1))); const timer = setTimeout(() => { retryTimers.delete(post); if (sessionActive() && post.isConnected && visiblePrefetchNodes.has(post)) { enqueueNestedPrefetch(post); } }, Math.max(250, delay)); retryTimers.set(post, timer); } async function fetchReplyPage(postId, after = 0, options = {}) { assertSessionActive(); if (!postId) return []; const normalizedAfter = Math.max(0, +after || 0); const pageKey = `${postId}:${normalizedAfter}`; if (replyPagesByKey.has(pageKey)) { const pendingSince = +(pendingReplyPageStarts.get(pageKey) || 0); const cached = await replyPagesByKey.get(pageKey); if (options.refresh !== true || pendingSince && responseCacheValueStoredAt(cached) >= pendingSince) return cached; replyPagesByKey.delete(pageKey); } const url = new URL(`${BASE}/posts/${postId}/replies.json`); if (normalizedAfter > 0) url.searchParams.set('after', String(normalizedAfter)); const request = fetchJSON(url.toString(), { scheduler: ctx.postRequestScheduler, fetchOptions: { credentials: 'include', cache: 'no-store' }, cacheMode: options.refresh === true ? 'refresh' : 'default', key: replyRequestKey(postId, normalizedAfter), priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.nested, }); pendingReplyPageStarts.set(pageKey, Date.now()); replyPagesByKey.set(pageKey, request); try { const payload = await request; assertSessionActive(); replyPagesByKey.set(pageKey, payload); return payload; } catch (error) { replyPagesByKey.delete(pageKey); throw error; } finally { pendingReplyPageStarts.delete(pageKey); } } async function loadDirectReplies(post, options = {}) { assertSessionActive(); const postId = +(post && post.id || 0); const postNumber = +(post && post.post_number || 0); if (!postId || !postNumber) return []; const knownMap = ctx.directRepliesByParent.get(postNumber); let merged = mergeDirectReplies(knownMap ? Array.from(knownMap.values()) : []); const expected = Math.max(0, +(post.reply_count || 0)); if ((options.force !== true || completeDirectReplyPostIds.has(postId)) && expected <= merged.length) return merged; let after = 0; const seenCursors = new Set(); do { const payload = await fetchReplyPage( postId, after, { priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background, refresh: options.force === true, } ); const pagePosts = postsFromPayload(payload); const direct = directRepliesForParent(pagePosts, postNumber); direct.forEach((reply) => rememberPostData(ctx, reply, postNumber)); ctx.loader.persistFetchedPosts(direct); merged = mergeDirectReplies(merged, direct); const nextAfter = replyPageCursor(pagePosts); if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE || nextAfter <= after || seenCursors.has(nextAfter)) break; seenCursors.add(nextAfter); after = nextAfter; } while (!expected || merged.length < expected); completeDirectReplyPostIds.add(postId); return merged; } function mergeReplyPageIntoState(state, payload, postNumber, expectedCount, after = 0) { const pagePosts = postsFromPayload(payload); const pageReplies = directRepliesForParent(pagePosts, postNumber); const nextAfter = replyPageCursor(pagePosts); pageReplies.forEach((reply) => rememberPostData(ctx, reply, postNumber)); ctx.loader.persistFetchedPosts(pageReplies); state.all = mergeDirectReplies(state.all, pageReplies); state.fetchedReplyCount = after > 0 ? +(state.fetchedReplyCount || 0) + pageReplies.length : pageReplies.length; state.fetchAfter = Math.max(after, nextAfter); const previousTotalCount = after > 0 ? +(state.totalCount || 0) : 0; const totalCount = Math.max(state.all.length, previousTotalCount, +(expectedCount || 0)); state.hasMore = nextAfter > after && pagePosts.length >= SUB_REPLY_FETCH_SIZE && state.fetchedReplyCount < totalCount; state.totalCount = state.hasMore ? totalCount : state.all.length; return pagePosts; } async function loadNextReplyPages(post, requiredCount) { if (!sessionActive()) return false; const postNumber = +(post && post.dataset.postNumber || 0); const state = ctx.subReplyState.get(postNumber); if (!postNumber || !state || state.loadingMore || state.hasMore === false) return false; const loadingEl = post.querySelector(':scope > .ldp-sub-loading'); state.loadingMore = true; if (loadingEl) loadingEl.style.display = 'block'; renderSubReplyBatch(postNumber, ctx, 0, post); try { while (state.hasMore !== false && +(state.fetchedReplyCount || 0) < requiredCount) { const after = +(state.fetchAfter || 0); if (!after) { state.hasMore = false; break; } const payload = await fetchReplyPage(post.dataset.postId, after, { priority: POST_REQUEST_PRIORITY.scroll, }); if (!sessionActive() || !post.isConnected) return false; const pagePosts = mergeReplyPageIntoState( state, payload, postNumber, post.dataset.childReplyTotal, after ); if (state.fetchAfter <= after || !pagePosts.length) break; } return state.all.length >= requiredCount; } catch (error) { return false; } finally { state.loadingMore = false; if (loadingEl) loadingEl.style.display = 'none'; if (sessionActive() && post.isConnected) renderSubReplyBatch(postNumber, ctx, 0, post); } } async function loadRepliesFor(post, priority = POST_REQUEST_PRIORITY.nested) { if (!sessionActive()) return false; const postId = post && post.dataset.postId; const postNumber = +(post && post.dataset.postNumber); if (!postId || fetchedNodes.has(post)) return false; fetchedNodes.add(post); const loadingEl = post.querySelector(':scope > .ldp-sub-loading'); if (loadingEl) loadingEl.style.display = 'block'; const expectedCount = +(post.dataset.childReplyTotal || 0); const knownReplyMap = ctx.directRepliesByParent.get(postNumber); const knownReplies = knownReplyMap ? Array.from(knownReplyMap.values()) : []; if (knownReplies.length) { const previousState = ctx.subReplyState.get(postNumber); const knownComplete = knownReplies.length >= expectedCount; const previousHasCursor = +(previousState && previousState.fetchAfter || 0) > 0; const needsInitialFetch = !knownComplete && !previousHasCursor && (!previousState || previousState.hasMore !== false); ctx.subReplyState.set(postNumber, { ...previousState, all: mergeDirectReplies(knownReplies), pageIndex: +(previousState && previousState.pageIndex || 0), totalCount: previousState && previousState.hasMore === false ? Math.max(knownReplies.length, +(previousState.totalCount || 0)) : Math.max(expectedCount, knownReplies.length), fetchedReplyCount: knownComplete ? knownReplies.length : +(previousState && previousState.fetchedReplyCount || 0), fetchAfter: +(previousState && previousState.fetchAfter || 0), hasMore: knownComplete ? false : previousState ? previousState.hasMore !== false : true, loadingMore: needsInitialFetch, }); renderSubReplyBatch(postNumber, ctx, 0, post); if (!needsInitialFetch) { if (loadingEl) loadingEl.style.display = 'none'; emptyRetryCounts.delete(post); return true; } } try { const replies = await fetchReplyPage(postId, 0, { priority }); if (!sessionActive()) return false; if (!post.isConnected) { fetchedNodes.delete(post); return false; } const previousState = ctx.subReplyState.get(postNumber); const nextState = { ...previousState, all: knownReplies, pageIndex: +(previousState && previousState.pageIndex || 0), loadingMore: false, }; mergeReplyPageIntoState(nextState, replies, postNumber, expectedCount); const directReplies = nextState.all; if (loadingEl) loadingEl.style.display = 'none'; if (!directReplies.length) { fetchedNodes.delete(post); replyPagesByKey.delete(`${postId}:0`); const retryCount = +(emptyRetryCounts.get(post) || 0); if (expectedCount > 0 && retryCount < 2 && post.isConnected) { await clearStoredResponseCache({ tags: [`post:${postId}`] }); emptyRetryCounts.set(post, retryCount + 1); const retryTimer = setTimeout(() => { retryTimers.delete(post); enqueueNestedPrefetch(post); }, 1200 * (retryCount + 1)); retryTimers.set(post, retryTimer); } return false; } emptyRetryCounts.delete(post); ctx.subReplyState.set(postNumber, nextState); renderSubReplyBatch(postNumber, ctx, 0, post); // 首批只渲染当前分页条数 return true; } catch (e) { if (loadingEl) loadingEl.style.display = 'none'; const state = ctx.subReplyState.get(postNumber); if (state) { state.loadingMore = false; renderSubReplyBatch(postNumber, ctx, 0, post); } fetchedNodes.delete(post); // 失败允许该 DOM 副本下次进入视口重试 replyPagesByKey.delete(`${postId}:0`); throw e; } } const scheduleInitialLoad = (post) => { if (!sessionActive() || !post || !post.dataset.postId || fetchedNodes.has(post) || hoverTimers.has(post)) return; const timer = setTimeout(() => { hoverTimers.delete(post); hoverTimerNodes.delete(post); if (!sessionActive() || !post.isConnected) return; enqueueNestedPrefetch(post); }, REPLIES_HOVER_DELAY); hoverTimers.set(post, timer); hoverTimerNodes.add(post); }; const nestedPrefetchPixels = Math.round( Math.max(ctx.scrollRoot.clientHeight, window.innerHeight * 0.5) * streamPerformance(ctx).nestedPrefetchViewports ); const io = new IntersectionObserver((entries) => { if (!sessionActive()) return; entries.forEach((en) => { if (!en.target.dataset.postId) return; if (en.isIntersecting) { visiblePrefetchNodes.add(en.target); scheduleInitialLoad(en.target); } else { // 离开视口时若尚未真正发起请求,则取消该次触发 cancelNestedPrefetch(en.target); } }); }, { root: ctx.scrollRoot, rootMargin: `${nestedPrefetchPixels}px 0px ${nestedPrefetchPixels}px 0px`, threshold: 0.01 }); const nativeObserve = io.observe.bind(io); io.observe = (node) => { if (!node || fetchedNodes.has(node) || !(+(node.dataset.childReplyTotal || 0) > 0)) return; nativeObserve(node); }; const nativeUnobserve = io.unobserve.bind(io); io.unobserve = (node) => { cancelNestedPrefetch(node); nativeUnobserve(node); }; const nativeDisconnect = io.disconnect.bind(io); io.disconnect = () => { nestedPrefetchStopped = true; if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer); nestedPrefetchResumeTimer = 0; new Set([...hoverTimerNodes, ...retryTimers.keys()]).forEach((node) => cancelNestedPrefetch(node, false)); pendingPrefetchNodes.clear(); visiblePrefetchNodes.clear(); replyPagesByKey.clear(); pendingReplyPageStarts.clear(); completeDirectReplyPostIds.clear(); nativeDisconnect(); }; io.loadNextPage = (node, requiredCount) => loadNextReplyPages(node, requiredCount); io.loadDirectReplies = loadDirectReplies; return io; } /* ============ 12. 收藏 ============ */ function bindTopicBookmark(topic, ctx) { ctx.topicBookmarked = !!topic.bookmarked; syncTopicActionControls(ctx); return () => toggleReaderBookmark(topic, ctx, 'Topic'); } function syncCommentsHeaderPlacement(ctx) { if (!ctx || !ctx.commentsHeader || !ctx.streamNodeMap) return; if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return; const firstPost = ctx.streamNodeMap.get(1); const firstPostMounted = firstPost && firstPost.parentElement === ctx.commentsEl && ctx.streamMountedNodes && ctx.streamMountedNodes.has(firstPost); if (!firstPostMounted) { ctx.commentsHeader.hidden = true; return; } const children = firstPost.querySelector(':scope > .ldp-children'); const childLoading = firstPost.querySelector(':scope > .ldp-sub-loading'); const footerActions = firstPost.querySelector(':scope > .ldp-topic-footer-actions'); if (footerActions) { const replyEnd = childLoading || children; if (replyEnd && footerActions.previousElementSibling !== replyEnd) { firstPost.insertBefore(footerActions, replyEnd.nextSibling); } firstPost.insertBefore(ctx.commentsHeader, footerActions.nextSibling); } else if (children) firstPost.insertBefore(ctx.commentsHeader, (childLoading || children).nextSibling); else firstPost.appendChild(ctx.commentsHeader); ctx.commentsHeader.hidden = false; } function updateCommentsHeader(ctx) { const commentCount = Math.max(0, +(ctx && ctx.totalPosts || 0) - 1); if (ctx.countEl) ctx.countEl.textContent = `(${commentCount})`; if (ctx.emptyEl) ctx.emptyEl.style.display = ctx.totalPosts ? 'none' : ''; syncCommentsHeaderPlacement(ctx); } function bindReaderTopicPresence(ctx) { if (!ctx || !ctx.commentsHeader || !ctx.topicId) return () => {}; let presence = ctx.commentsHeader.querySelector(':scope > .ldp-topic-presence'); if (!presence) { presence = document.createElement('span'); presence.className = 'ldp-topic-presence'; presence.hidden = true; presence.setAttribute('aria-live', 'polite'); ctx.commentsHeader.appendChild(presence); } const channel = `/discourse-presence/reply/${ctx.topicId}`; const params = new URLSearchParams(); params.append('channels[]', channel); const url = `${BASE}/presence/get?${params.toString()}`; let stopped = false; let timer = 0; let controller = null; const render = (source) => { const users = (Array.isArray(source) ? source : []) .filter((user) => user && (!ME_USERNAME || user.username !== ME_USERNAME)); if (!users.length) { presence.hidden = true; presence.replaceChildren(); return; } const visibleUsers = users.slice(0, 3); const avatars = visibleUsers.map((user) => { const username = String(user.username || ''); const name = String(user.name || username || '用户'); const avatar = persistentAvatarHtml(user.avatar_template, 24, 'ldp-topic-presence-avatar', name); return `<a class="ldp-topic-presence-user ldp-user-link" href="${escAttr(userProfileUrl(username))}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}" aria-label="${escAttr(name)}">${avatar}</a>`; }).join(''); const first = users[0]; const firstName = String(first.name || first.username || '用户'); const label = users.length === 1 ? `${firstName} 正在回复…` : `${users.length} 人正在回复…`; presence.innerHTML = `<span class="ldp-topic-presence-avatars">${avatars}</span><span class="ldp-topic-presence-text">${esc(label)}</span>`; presence.hidden = false; hydratePersistentAvatarImages(presence, POST_REQUEST_PRIORITY.background); }; const schedule = () => { if (stopped || document.visibilityState !== 'visible') return; timer = setTimeout(refresh, 5000); }; const refresh = async () => { timer = 0; if (stopped || document.visibilityState !== 'visible') return; controller = new AbortController(); try { const data = await fetchJSON(url, { scheduler: ctx.postRequestScheduler, key: `presence:${ctx.topicId}`, priority: POST_REQUEST_PRIORITY.background, signal: controller.signal, cacheMode: 'refresh', }); if (!stopped) render(data && data[channel] && data[channel].users); } catch (error) { if (!stopped && error.name !== 'AbortError') render([]); } finally { controller = null; schedule(); } }; const onVisibilityChange = () => { if (document.visibilityState !== 'visible') { if (timer) clearTimeout(timer); timer = 0; if (controller) controller.abort(); return; } if (!timer && !controller) refresh(); }; document.addEventListener('visibilitychange', onVisibilityChange); if (document.visibilityState === 'visible') refresh(); return () => { stopped = true; document.removeEventListener('visibilitychange', onVisibilityChange); if (timer) clearTimeout(timer); if (controller) controller.abort(); timer = 0; controller = null; presence.hidden = true; presence.replaceChildren(); }; } const READER_LOADING_ANIMATIONS = Object.freeze([ { key: 'portal', label: '主题开卷', markup: '<div class="ldp-loading-visual-inner ldp-loader-portal"><span style="--i:0" data-copy="TOPIC"></span><span style="--i:1" data-copy="#1"></span><span style="--i:2"></span><span style="--i:3"></span><span style="--i:4"></span><span style="--i:5"></span></div>' }, { key: 'constellation', label: '回帖脉络', markup: '<div class="ldp-loading-visual-inner ldp-loader-thread-index"><span style="--i:0;--depth:0" data-user="OP" data-floor="#1"></span><span style="--i:1;--depth:1" data-user="↳ 回帖" data-floor="#2"></span><span style="--i:2;--depth:2" data-user="↳ 楼中楼" data-floor="#6"></span><span style="--i:3;--depth:1" data-user="↳ 回帖" data-floor="#9"></span><span style="--i:4;--depth:2" data-user="↳ 楼中楼" data-floor="#12"></span><span style="--i:5;--depth:3" data-user="↳ 继续回复" data-floor="#18"></span></div>' }, { key: 'corridor', label: '楼层时间轴', markup: '<div class="ldp-loading-visual-inner ldp-loader-floor-reel"><span style="--i:0" data-floor="#01" data-time="首帖"></span><span style="--i:1" data-floor="#02" data-time="回复"></span><span style="--i:2" data-floor="#03" data-time="当前"></span><span style="--i:3" data-floor="#04" data-time="回复"></span><span style="--i:4" data-floor="#05" data-time="最新"></span></div>' }, { key: 'typewave', label: 'Markdown 解析', markup: '<div class="ldp-loading-visual-inner ldp-loader-typewave"><span style="--row:0" data-source="# 标题"></span><span style="--row:1" data-source="**重点**"></span><span style="--row:2" data-source="> 引用"></span><span style="--row:0" data-render="标题"></span><span style="--row:1" data-render="重点"></span><span style="--row:2" data-render="引用内容"></span></div>' }, { key: 'crystal', label: '缓存回环', markup: '<div class="ldp-loading-visual-inner ldp-loader-cache-lanes"><span style="--i:0;--lane:0"></span><span style="--i:1;--lane:1"></span><span style="--i:2;--lane:2"></span><span style="--i:3;--lane:0"></span><span style="--i:4;--lane:1"></span><span style="--i:5;--lane:2"></span><span style="--i:6;--lane:0"></span><span style="--i:7;--lane:1"></span><span style="--i:8;--lane:2"></span><span style="--i:9;--lane:0"></span><span style="--i:10;--lane:1"></span><span style="--i:11;--lane:2"></span></div>' }, { key: 'marginalia', label: '只看楼主', markup: '<div class="ldp-loading-visual-inner ldp-loader-marginalia"><span style="--i:0" data-user="OP" data-floor="#1"></span><span style="--i:1" data-user="佬友" data-floor="#2"></span><span style="--i:2" data-user="OP" data-floor="#7"></span><span style="--i:3" data-user="佬友" data-floor="#8"></span><span style="--i:4" data-user="OP" data-floor="#16"></span></div>' }, { key: 'chapters', label: '分类标签', markup: '<div class="ldp-loading-visual-inner ldp-loader-index-fan"><span style="--i:0" data-index="01" data-tag="类别"></span><span style="--i:1" data-index="02" data-tag="标签"></span><span style="--i:2" data-index="03" data-tag="楼主"></span><span style="--i:3" data-index="04" data-tag="楼层"></span><span style="--i:4" data-index="05" data-tag="回复"></span></div>' }, { key: 'quoteecho', label: '社区信条', markup: '<div class="ldp-loading-visual-inner ldp-loader-quoteecho"><span style="--i:0" data-word="真诚"></span><span style="--i:1" data-word="友善"></span><span style="--i:2" data-word="团结"></span><span style="--i:3" data-word="专业"></span></div>' }, { key: 'footnotes', label: '新回复抵达', markup: '<div class="ldp-loading-visual-inner ldp-loader-inbox-rain"><span style="--i:0;--x:34px;--y:112px;--drop:-72px" data-floor="#128"></span><span style="--i:1;--x:77px;--y:98px;--drop:-88px" data-floor="#129"></span><span style="--i:2;--x:120px;--y:84px;--drop:-64px" data-floor="#130"></span><span style="--i:3;--x:163px;--y:70px;--drop:-94px" data-floor="#131"></span><span style="--i:4;--x:206px;--y:56px;--drop:-76px" data-floor="#132"></span></div>' }, { key: 'inkverse', label: '互动汇流', markup: '<div class="ldp-loading-visual-inner ldp-loader-inkverse"><span style="--i:0" data-action="赞"></span><span style="--i:1" data-action="Boost"></span><span style="--i:2" data-action="回应"></span><span style="--i:3" data-action="收藏"></span></div>' }, ]); function resolveReaderLoadingAnimation(preference = PREFS.loadingAnimation, randomKey = '', excludedKey = '') { const normalized = normalizeReaderLoadingAnimation(preference); if (normalized !== 'random') { return READER_LOADING_ANIMATIONS.find((animation) => animation.key === normalized) || READER_LOADING_ANIMATIONS[0]; } const requestedRandom = READER_LOADING_ANIMATIONS.find((animation) => animation.key === randomKey); if (requestedRandom) return requestedRandom; const candidates = READER_LOADING_ANIMATIONS.filter((animation) => animation.key !== excludedKey); const pool = candidates.length ? candidates : READER_LOADING_ANIMATIONS; return pool[Math.floor(Math.random() * pool.length)]; } function readerLoadingVisualHTML(animation) { return `<div class="ldp-loading-visual" data-animation="${animation.key}" aria-hidden="true"> ${animation.markup} </div>`; } let lastRandomReaderLoadingAnimationKey = ''; function createReaderLoadingHTML(preference = PREFS.loadingAnimation) { const normalized = normalizeReaderLoadingAnimation(preference); const animation = resolveReaderLoadingAnimation(normalized, '', normalized === 'random' ? lastRandomReaderLoadingAnimationKey : ''); if (normalized === 'random') lastRandomReaderLoadingAnimationKey = animation.key; return ` <div class="ldp-loading-stage" role="status" aria-live="polite" aria-atomic="true" aria-label="正在载入"> ${readerLoadingVisualHTML(animation)} <div class="ldp-loading-copy"> <div class="ldp-loading-mode">LINUX DO READER · ${animation.label}</div> <div class="ldp-loading-status"><span>正在载入帖子</span><strong class="ldp-loading-target"></strong></div> <div class="ldp-loading-detail">正在准备阅读现场…</div> </div> </div>`; } function createReaderCommentsHeader() { const commentsHeader = document.createElement('div'); commentsHeader.className = 'ldp-comments-header'; commentsHeader.hidden = true; commentsHeader.innerHTML = `${icon('messageSquare')}<span>评论</span><span class="ldp-comments-count" aria-live="polite"></span>`; return commentsHeader; } function parkReaderCommentsHeader(overlay, preferredHeader = null) { const body = overlay && overlay.querySelector('.ldp-body'); if (!body) return null; const readerShell = overlay._ldpReaderShell; const commentsHeader = preferredHeader || (readerShell && readerShell.commentsHeader) || overlay.querySelector('.ldp-comments-header') || createReaderCommentsHeader(); const loadingTip = body.querySelector('.ldp-loading-tip'); commentsHeader.hidden = true; commentsHeader.remove(); if (loadingTip) body.insertBefore(commentsHeader, loadingTip); else body.appendChild(commentsHeader); if (readerShell) readerShell.commentsHeader = commentsHeader; return commentsHeader; } function resetReaderTopicShell(overlay) { const body = overlay && overlay.querySelector('.ldp-body'); if (!body) return; const comments = body.querySelector('.ldp-comments'); parkReaderCommentsHeader(overlay); const loadingTip = body.querySelector('.ldp-loading-tip'); const endTip = body.querySelector('.ldp-end-tip'); const timeline = overlay.querySelector('.ldp-topic-timeline'); const title = overlay.querySelector('.ldp-title-jump'); const metaStats = overlay.querySelector('.ldp-meta-stats'); const metaOwner = overlay.querySelector('.ldp-meta-owner'); const tags = overlay.querySelector('.ldp-topic-tags'); const voteSlot = overlay.querySelector('.ldp-topic-vote-slot'); const onlyOp = overlay.querySelector('.ldp-only-op-toggle'); const onlyOpProgress = overlay.querySelector('.ldp-only-op-progress'); const nativeOpen = overlay.querySelector('.ldp-open'); const liveUpdate = overlay.querySelector('.ldp-live-update'); const rateLimitNotice = overlay.querySelector('.ldp-rate-limit-notice'); body.querySelector('.ldp-loadmask')?.remove(); const mask = document.createElement('div'); mask.className = 'ldp-loadmask'; mask.innerHTML = createReaderLoadingHTML(); body.prepend(mask); if (comments) { const empty = document.createElement('div'); empty.className = 'ldp-comments-empty'; empty.style.display = 'none'; empty.textContent = '暂无评论'; comments.replaceChildren(empty); } if (loadingTip) loadingTip.classList.remove('show'); if (endTip) endTip.classList.remove('show'); if (liveUpdate) liveUpdate.hidden = true; if (rateLimitNotice) { rateLimitNotice.hidden = true; delete rateLimitNotice.dataset.cooldownSeconds; } if (timeline) { timeline.hidden = true; timeline.querySelector('.ldp-topic-timeline-current')?.replaceChildren(); timeline.querySelector('.ldp-topic-timeline-total')?.replaceChildren(); timeline.querySelector('.ldp-topic-timeline-preview')?.replaceChildren(); } if (title) title.textContent = '正在切换主题…'; if (metaStats) metaStats.textContent = '正在加载主题…'; if (metaOwner) metaOwner.hidden = true; if (tags) { tags.replaceChildren(); tags.hidden = true; } if (voteSlot) { voteSlot.replaceChildren(); voteSlot.hidden = true; } if (onlyOp) { onlyOp.disabled = true; onlyOp.classList.remove('active'); onlyOp.setAttribute('aria-pressed', 'false'); onlyOp.setAttribute('aria-label', '只看楼主'); } if (onlyOpProgress) { onlyOpProgress.hidden = true; onlyOpProgress.dataset.state = ''; onlyOpProgress.style.removeProperty('--ldp-only-op-progress'); } if (nativeOpen) { nativeOpen.hidden = true; nativeOpen.removeAttribute('href'); } body.scrollTop = 0; } /* ============ 13. 弹窗主体 + 循环泵加载 ============ */ let CURRENT_OVERLAY = null; let READER_SESSION_SEQUENCE = 0; let READER_OPEN_REQUEST_SEQUENCE = 0; let ACTIVE_READER_SESSION_ID = 0; let HOST_DOCUMENT_TITLE = null; let AUTO_OPEN_SUPPRESSED_TOPIC_ID = bypassReaderForThisTab ? String(INITIAL_TOPIC_ID || '') : ''; function syncReaderDocumentTitle(topicTitle) { const title = String(topicTitle || '').trim(); if (!title) return; if (HOST_DOCUMENT_TITLE === null) HOST_DOCUMENT_TITLE = document.title; document.title = title; } function restoreHostDocumentTitle() { if (HOST_DOCUMENT_TITLE === null) return; document.title = HOST_DOCUMENT_TITLE; HOST_DOCUMENT_TITLE = null; } function currentHostRouteKey() { return `${location.pathname}${location.search}${location.hash}`; } function embeddedReaderViewportState(ctx) { if (!ctx || !ctx.scrollRoot) return null; const rootRect = ctx.scrollRoot.getBoundingClientRect(); const visible = Array.from(ctx.streamMountedNodes || []) .filter((node) => node && node.isConnected && +node.dataset.postNumber > 0) .map((node) => ({ node, rect: node.getBoundingClientRect() })) .filter((item) => item.rect.bottom > rootRect.top && item.rect.top < rootRect.bottom) .sort((left, right) => left.rect.top - right.rect.top)[0]; return { postNumber: visible ? +visible.node.dataset.postNumber : 0, postOffset: visible ? visible.rect.top - rootRect.top : 0, scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0), }; } function saveEmbeddedReaderReloadState() { const overlay = CURRENT_OVERLAY; const readerShell = overlay && overlay._ldpReaderShell; const readerWorkspace = readerShell && readerShell.readerWorkspace; const ctx = readerShell && readerShell.activeContext; if (!overlay || !readerWorkspace || !readerWorkspace.isEmbedded() || !ctx || !ctx.readerSession || !ctx.readerSession.isCurrent('opening', 'active')) return; const viewport = embeddedReaderViewportState(ctx) || {}; const state = { savedAt: Date.now(), hostRoute: currentHostRouteKey(), mode: readerWorkspace.mode(), topicId: Number(overlay.dataset.topicId || ctx.topicId) || 0, targetPostNumber: Number(viewport.postNumber || readerShell.targetPostNumber) || 1, postOffset: Number(viewport.postOffset) || 0, scrollTop: Number(viewport.scrollTop) || 0, onlyOp: ctx.onlyOp === true, topicNavMetadata: readerShell.topicNavMetadata || null, }; if (!state.topicId) return; try { sessionStorage.setItem(LDP_EMBEDDED_RELOAD_STATE_KEY, JSON.stringify(state)); } catch (e) {} } function readEmbeddedReaderReloadState({ consume = false } = {}) { let state; try { state = JSON.parse(sessionStorage.getItem(LDP_EMBEDDED_RELOAD_STATE_KEY) || 'null'); if (consume) sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY); } catch (e) { if (consume) { try { sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY); } catch (storageError) {} } return null; } const navigationEntry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; const reloaded = navigationEntry ? navigationEntry.type === 'reload' : performance.navigation?.type === 1; if (!reloaded || !state || Date.now() - Number(state.savedAt || 0) > LDP_EMBEDDED_RELOAD_STATE_TTL || state.hostRoute !== currentHostRouteKey() || !isEmbeddedListReaderMode(state.mode) || !(Number(state.topicId) > 0)) { return null; } return state; } async function restoreEmbeddedReaderAfterReload() { const state = readEmbeddedReaderReloadState({ consume: true }); if (!state) return false; setPref('listReaderMode', state.mode); await openModal(state.topicId, { targetPostNumber: state.targetPostNumber, topicNavMetadata: state.topicNavMetadata || undefined, restoreOnlyOp: state.onlyOp === true, restoreViewport: { postNumber: state.targetPostNumber, postOffset: Number(state.postOffset) || 0, scrollTop: Number(state.scrollTop) || 0, }, }); return true; } function timelineMonthLabel(createdAt) { const date = new Date(createdAt || 0); if (!Number.isFinite(date.getTime())) return ''; return `${date.getFullYear()} 年\n${date.getMonth() + 1} 月 ${date.getDate()} 日`; } function bindTopicTimeline(timeline, ctx) { const trackTopInset = 10; const trackBottomInset = 10; const track = timeline.querySelector('.ldp-topic-timeline-track'); const dateEl = timeline.querySelector('.ldp-topic-timeline-date'); const currentEl = timeline.querySelector('.ldp-topic-timeline-current'); const totalEl = timeline.querySelector('.ldp-topic-timeline-total'); const relativeEl = timeline.querySelector('.ldp-topic-timeline-relative'); const previewEl = timeline.querySelector('.ldp-topic-timeline-preview'); const cursorEl = timeline.querySelector('.ldp-topic-timeline-cursor'); const lensFloorElements = new Map(); const timelineEventBindings = []; let frame = 0; let previewFrame = 0; let jumpAnimationFrame = 0; let jumpAnimationTimer = 0; let previewClientY = 0; let previewVisible = false; let previewTarget = 0; let dragging = false; let activePointerId = 0; let dragTarget = 1; let stablePostNumber = 0; let stableCreatedAt = ''; let lensTargetPostNumber = 0; let jumpSequence = 0; const totalPostCount = () => Math.max(1, ctx.totalPosts || ctx.streamItems.length || 1); const onlyOpTimelineItems = () => ctx.onlyOp ? streamLayoutItems(ctx) : null; const progressForPost = (postNumber) => { const items = onlyOpTimelineItems(); if (items && items.length) { const item = ctx.streamItemMap.get(+postNumber); const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex) ? item.layoutIndex : -1; if (index >= 0) return items.length > 1 ? index / (items.length - 1) : 0; } const total = totalPostCount(); const current = Math.max(1, Math.min(total, +postNumber || 1)); return total > 1 ? (current - 1) / (total - 1) : 0; }; const targetFromRatio = (ratio) => { const items = onlyOpTimelineItems(); if (items && items.length) { const index = Math.round(ratio * (items.length - 1)); return +items[Math.max(0, Math.min(items.length - 1, index))].postNumber; } const total = totalPostCount(); return Math.max(1, Math.min(total, Math.round(1 + ratio * (total - 1)))); }; const targetFromKey = (current, delta) => { const items = onlyOpTimelineItems(); if (items && items.length) { const item = ctx.streamItemMap.get(+current); const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex) ? item.layoutIndex : 0; return +items[Math.max(0, Math.min(items.length - 1, index + delta))].postNumber; } const total = totalPostCount(); return Math.max(1, Math.min(total, current + delta)); }; const timelineLensLayout = () => { const items = onlyOpTimelineItems(); if (items && items.length) { return { length: items.length, floorAt: (index) => +items[index].postNumber }; } return { length: totalPostCount(), floorAt: (index) => index + 1 }; }; const timelineLensProfile = (offset) => { const distance = Math.abs(offset); const focus = Math.exp(-0.72 * distance * distance); return { opacity: Math.pow(Math.max(0, 1 - distance / 3), 1.25), scale: 0.46 + 0.54 * focus, shiftX: -Math.min(6, distance * 2), }; }; const renderTimelineLensAtRatio = (ratio) => { if (!cursorEl) return; const layout = timelineLensLayout(); const continuousIndex = Math.max(0, Math.min(layout.length - 1, ratio * (layout.length - 1))); const nearestIndex = Math.round(continuousIndex); const selectedFloor = layout.floorAt(nearestIndex); const firstIndex = Math.max(0, nearestIndex - 3); const lastIndex = Math.min(layout.length - 1, nearestIndex + 3); const desiredFloors = new Map(); for (let index = firstIndex; index <= lastIndex; index++) { desiredFloors.set(layout.floorAt(index), index - continuousIndex); } lensFloorElements.forEach((element, floor) => { if (desiredFloors.has(floor)) return; element.remove(); lensFloorElements.delete(floor); }); desiredFloors.forEach((offset, floor) => { let element = lensFloorElements.get(floor); if (!element) { element = document.createElement('span'); element.textContent = `#${floor}`; lensFloorElements.set(floor, element); cursorEl.append(element); } const profile = timelineLensProfile(offset); element.style.setProperty('--ldp-lens-offset', String(offset)); element.style.setProperty('--ldp-lens-scale', profile.scale.toFixed(4)); element.style.setProperty('--ldp-lens-opacity', profile.opacity.toFixed(4)); element.style.setProperty('--ldp-lens-shift-x', `${profile.shiftX.toFixed(2)}px`); element.classList.toggle('ldp-timeline-lens-selected', floor === selectedFloor); element.classList.toggle('ldp-timeline-lens-focus', Math.abs(offset) < 0.001); }); lensTargetPostNumber = selectedFloor || lensTargetPostNumber; }; const renderPosition = (postNumber, createdAt = '') => { const total = totalPostCount(); const current = Math.max(1, Math.min(total, +postNumber || 1)); const progress = progressForPost(current); timeline.style.setProperty('--ldp-timeline-progress', String(progress)); currentEl.textContent = String(current); totalEl.textContent = String(total); dateEl.textContent = timelineMonthLabel(createdAt); dateEl.disabled = !dateEl.textContent; const latestReplyAt = ctx.latestReplyAt || (ctx.topicData && ctx.topicData.last_posted_at) || ''; relativeEl.textContent = latestReplyAt ? fmtTime(latestReplyAt) : ''; relativeEl.disabled = !latestReplyAt; track.setAttribute('aria-valuemin', '1'); track.setAttribute('aria-valuemax', String(total)); track.setAttribute('aria-valuenow', String(current)); track.setAttribute('aria-valuetext', `第 ${current} 楼,共 ${total} 楼`); if (previewVisible) { track.classList.toggle('ldp-timeline-previewing', previewTarget > 0 && previewTarget !== current); } timeline.hidden = total <= 1; }; const renderStablePosition = (postNumber, createdAt = '') => { stablePostNumber = +postNumber || stablePostNumber; stableCreatedAt = createdAt || stableCreatedAt; renderPosition(stablePostNumber, stableCreatedAt); }; const renderBoundaryPosition = (postNumber) => { const node = ctx.streamNodeMap.get(postNumber); const createdAt = (node && node.dataset.createdAt) || (postNumber === 1 ? ctx.topicData?.created_at : (ctx.latestReplyAt || ctx.topicData?.last_posted_at)) || ''; renderStablePosition(postNumber, createdAt); }; const updateNow = () => { frame = 0; if (!ctx.streamItems.length) { timeline.hidden = true; return; } const atScrollStart = ctx.scrollRoot.scrollTop <= 10; const lastLoadedPostNumber = +(ctx.streamItems[ctx.streamItems.length - 1]?.postNumber || 0); if (atScrollStart && ctx.streamItemMap.has(1)) { renderBoundaryPosition(1); return; } const layoutItems = streamLayoutItems(ctx); const atScrollEnd = ctx.scrollRoot.scrollTop + ctx.scrollRoot.clientHeight >= ctx.scrollRoot.scrollHeight - 16; const atLoadedEnd = atScrollEnd && lastLoadedPostNumber >= Math.max(1, ctx.totalPosts || 1); if (atLoadedEnd) { const lastTimelineItem = ctx.onlyOp ? layoutItems[layoutItems.length - 1] : null; renderBoundaryPosition(lastTimelineItem ? lastTimelineItem.postNumber : totalPostCount()); return; } const anchoredOwnerNode = ctx.streamViewportAnchor && ctx.streamViewportAnchor.owner; const visibleAnchor = anchoredOwnerNode && anchoredOwnerNode.isConnected ? null : findTopVisibleStreamPost(ctx); const visibleOwnerNode = anchoredOwnerNode && anchoredOwnerNode.isConnected ? anchoredOwnerNode : visibleAnchor && visibleAnchor.ownerNode; const visiblePostNumber = streamPostNumber(visibleOwnerNode); const visibleItem = visiblePostNumber && ctx.streamItemMap.get(visiblePostNumber); if (visiblePostNumber && (!ctx.onlyOp || streamItemMatchesFilter(ctx, visibleItem))) { renderStablePosition(visiblePostNumber, visibleOwnerNode.dataset.createdAt || ''); return; } if (stablePostNumber) renderPosition(stablePostNumber, stableCreatedAt); }; const update = () => { if (frame || dragging) return; frame = requestAnimationFrame(updateNow); }; const sync = () => { if (dragging) return; if (frame) cancelAnimationFrame(frame); frame = 0; updateNow(); }; const releaseJump = () => { jumpSequence++; cancelReaderNavigation(ctx); update(); }; const refreshRelativeTimes = () => refreshPostRelativeTimes(ctx.commentsEl); let relativeTimer = 0; const setActive = (active) => { if (relativeTimer) clearInterval(relativeTimer); relativeTimer = 0; if (!active) return; relativeTimer = setInterval(() => { update(); refreshRelativeTimes(); }, 30000); }; setActive(document.visibilityState === 'visible'); const timelinePointerPosition = (clientY) => { const rect = track.getBoundingClientRect(); const usableHeight = Math.max(1, rect.height - trackTopInset - trackBottomInset); const top = Math.max(trackTopInset, Math.min(rect.height - trackBottomInset, clientY - rect.top)); return { top, ratio: Math.max(0, Math.min(1, (top - trackTopInset) / usableHeight)) }; }; const pointerRatio = (clientY) => timelinePointerPosition(clientY).ratio; const targetFromPointer = (clientY) => targetFromRatio(pointerRatio(clientY)); const showPointerPreview = (clientY) => { previewVisible = true; previewClientY = clientY; if (previewFrame) return; previewFrame = requestAnimationFrame(() => { previewFrame = 0; if (!previewVisible) return; const pointerPosition = timelinePointerPosition(previewClientY); const target = targetFromRatio(pointerPosition.ratio); const progress = pointerPosition.ratio; const current = +(track.getAttribute('aria-valuenow') || 0); previewTarget = target; timeline.style.setProperty('--ldp-timeline-preview-progress', String(progress)); timeline.style.setProperty('--ldp-timeline-lens-top', `${pointerPosition.top}px`); renderTimelineLensAtRatio(progress); if (dragging) previewPointerTarget(previewClientY, target); previewEl.textContent = `#${target}`; track.classList.toggle('ldp-timeline-previewing', target !== current); track.classList.add('ldp-timeline-hovering'); }); }; const hidePointerPreview = () => { previewVisible = false; previewTarget = 0; if (previewFrame) cancelAnimationFrame(previewFrame); previewFrame = 0; track.classList.remove('ldp-timeline-previewing', 'ldp-timeline-hovering'); }; const finishTimelineJumpAnimation = () => { if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame); if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer); jumpAnimationFrame = 0; jumpAnimationTimer = 0; track.classList.remove('ldp-timeline-jumping'); if (previewVisible) showPointerPreview(previewClientY); }; const beginTimelineJumpAnimation = (targetPostNumber) => { const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reducedMotion || !cursorEl) return; if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame); if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer); jumpAnimationFrame = 0; jumpAnimationTimer = 0; const currentPostNumber = stablePostNumber || +(track.getAttribute('aria-valuenow') || 1); const alreadyFocusedOnTarget = previewVisible && lensTargetPostNumber === targetPostNumber; const currentProgress = progressForPost(currentPostNumber); const targetProgress = progressForPost(targetPostNumber); if (!alreadyFocusedOnTarget) { timeline.style.removeProperty('--ldp-timeline-lens-top'); timeline.style.setProperty('--ldp-timeline-preview-progress', String(currentProgress)); renderTimelineLensAtRatio(currentProgress); } track.classList.remove('ldp-timeline-jumping'); void cursorEl.offsetWidth; track.classList.add('ldp-timeline-jumping'); const shouldTravel = !alreadyFocusedOnTarget && Math.abs(targetProgress - currentProgress) > 0.0001; if (!shouldTravel) { jumpAnimationFrame = requestAnimationFrame(() => { jumpAnimationFrame = 0; renderTimelineLensAtRatio(targetProgress); jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 460); }); return; } const duration = 360; let startedAt = 0; const animateJump = (timestamp) => { if (!startedAt) startedAt = timestamp; const elapsedRatio = Math.min(1, (timestamp - startedAt) / duration); const easedRatio = 1 - Math.pow(1 - elapsedRatio, 3); const progress = currentProgress + (targetProgress - currentProgress) * easedRatio; timeline.style.setProperty('--ldp-timeline-preview-progress', String(progress)); renderTimelineLensAtRatio(progress); if (elapsedRatio < 1) { jumpAnimationFrame = requestAnimationFrame(animateJump); return; } jumpAnimationFrame = 0; jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 300); }; jumpAnimationFrame = requestAnimationFrame(animateJump); }; const previewPointerTarget = (clientY, targetPostNumber = targetFromPointer(clientY)) => { dragTarget = targetPostNumber; const node = ctx.streamNodeMap.get(dragTarget); renderPosition(dragTarget, node ? node.dataset.createdAt : ''); }; const jumpTo = async (targetPostNumber, options = {}) => { targetPostNumber = +targetPostNumber || 0; if (!targetPostNumber) return false; beginTimelineJumpAnimation(targetPostNumber); const currentJumpSequence = ++jumpSequence; const navigationSequence = beginReaderNavigation(ctx); const jumpStillCurrent = () => currentJumpSequence === jumpSequence && readerNavigationIsCurrent(ctx, navigationSequence) && (typeof options.acceptsWork !== 'function' || options.acceptsWork()); if (!jumpStillCurrent()) return false; if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition(); const loaded = !ctx.loadPost || await ctx.loadPost(targetPostNumber, { ensureAround: targetPostNumber > 1, scope: targetPostNumber > 1 ? 'around' : 'single', }); if (!jumpStillCurrent()) return false; if (!loaded) { update(); return false; } if (!jumpStillCurrent()) return false; let visualTarget = null; const scrollOptions = { ...PINNED_STREAM_SCROLL_OPTIONS, resolveVisualTarget: typeof options.prepareTarget === 'function' ? () => { visualTarget = options.prepareTarget(targetPostNumber); return visualTarget; } : null, alignCenter: options.alignCenter === true, alignNearest: options.alignNearest === true, alignTop: options.alignBottom !== true, alignBottom: options.alignBottom === true, highlight: options.highlight !== false, }; if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) { update(); return false; } await waitForReaderJumpLayout(); if (!jumpStillCurrent()) return false; const settledTarget = visualTarget && visualTarget.isConnected ? visualTarget : getStreamPostNode(ctx, targetPostNumber); if (!readerTargetNodeIsVisible(settledTarget, ctx)) { clearStreamProgrammaticTarget(ctx); update(); return false; } sync(); return true; }; const jumpToBoundary = async (targetPostNumber, options = {}) => { const sequenceBeforeAttempt = jumpSequence; let jumped = await jumpTo(targetPostNumber, options); if (jumped) return true; const attemptWasNotCancelled = jumpSequence === sequenceBeforeAttempt + 1 && (typeof options.acceptsWork !== 'function' || options.acceptsWork()); if (!attemptWasNotCancelled) return false; ctx.streamWindowResetPending = true; syncStreamWindow(ctx); await waitForReaderJumpLayout(); if (jumpSequence !== sequenceBeforeAttempt + 1) return false; jumped = await jumpTo(targetPostNumber, options); return jumped; }; const completeBoundaryJump = async (targetPostNumber, atEnd, options = {}) => { if (!await jumpToBoundary(targetPostNumber, options)) return false; const completedJumpSequence = jumpSequence; if (!atEnd) ctx.streamWindowResetPending = true; const scrollToBoundary = () => { const previousScrollTop = ctx.scrollRoot.scrollTop; ctx.scrollRoot.scrollTop = atEnd ? ctx.scrollRoot.scrollHeight : 0; markStreamInternalScroll(ctx, previousScrollTop); }; scrollToBoundary(); syncStreamWindow(ctx); await waitForReaderJumpLayout(); if (completedJumpSequence !== jumpSequence) return false; scrollToBoundary(); renderBoundaryPosition(targetPostNumber); return true; }; let startJumpPromise = null; const jumpToStart = (options = {}) => { if (startJumpPromise) return startJumpPromise; startJumpPromise = completeBoundaryJump(1, false, options) .finally(() => { startJumpPromise = null; }); return startJumpPromise; }; let endJumpPromise = null; const jumpToEnd = () => { if (endJumpPromise) return endJumpPromise; endJumpPromise = (async () => { const items = onlyOpTimelineItems(); let target = items && items.length ? items[items.length - 1].postNumber : totalPostCount(); if (!items && ctx.loader && typeof ctx.loader.loadLastPost === 'function') { try { const lastPosts = await ctx.loader.loadLastPost({ priority: POST_REQUEST_PRIORITY.target }); const lastPost = lastPosts.reduce((latest, post) => +(post && post.post_number || 0) > +(latest && latest.post_number || 0) ? post : latest, null); if (lastPost) { attachPostBatch(lastPosts.filter((post) => !ctx.streamNodeMap.has(+post.post_number)), ctx); target = +lastPost.post_number; } } catch (error) {} } return completeBoundaryJump(target, true, { alignBottom: true }); })().finally(() => { endJumpPromise = null; }); return endJumpPromise; }; const onDateClick = () => timeline.querySelector('.ldp-topic-timeline-top')?.click(); const onRelativeClick = async () => { if (!await jumpToEnd()) showSelectionToast('最后一楼加载失败,请重试'); }; const onTrackPointerEnter = (e) => showPointerPreview(e.clientY); const onTrackPointerLeave = () => { if (!dragging) hidePointerPreview(); }; const onTrackPointerDown = (e) => { e.preventDefault(); dragging = true; activePointerId = e.pointerId; dragTarget = targetFromPointer(e.clientY); track.setPointerCapture(e.pointerId); showPointerPreview(e.clientY); }; const onTrackPointerMove = (e) => { if (previewVisible) showPointerPreview(e.clientY); }; const onTrackPointerUp = (e) => { if (!dragging) return; dragging = false; if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); activePointerId = 0; dragTarget = targetFromPointer(e.clientY); if (!track.matches(':hover')) hidePointerPreview(); jumpTo(dragTarget); }; const onTrackPointerCancel = () => { dragging = false; activePointerId = 0; hidePointerPreview(); update(); }; const onTrackKeyDown = (e) => { const current = +(track.getAttribute('aria-valuenow') || 1); let target; if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') target = targetFromKey(current, -1); else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') target = targetFromKey(current, 1); else if (e.key === 'PageUp') target = targetFromKey(current, -streamPerformance(ctx).pageSize); else if (e.key === 'PageDown') target = targetFromKey(current, streamPerformance(ctx).pageSize); else if (e.key === 'Home') target = targetFromKey(current, -Infinity); else if (e.key === 'End') target = targetFromKey(current, Infinity); else return; e.preventDefault(); jumpTo(target); }; addTrackedEventListener(timelineEventBindings, dateEl, 'click', onDateClick); addTrackedEventListener(timelineEventBindings, relativeEl, 'click', onRelativeClick); addTrackedEventListener(timelineEventBindings, track, 'pointerenter', onTrackPointerEnter); addTrackedEventListener(timelineEventBindings, track, 'pointerleave', onTrackPointerLeave); addTrackedEventListener(timelineEventBindings, track, 'pointerdown', onTrackPointerDown); addTrackedEventListener(timelineEventBindings, track, 'pointermove', onTrackPointerMove); addTrackedEventListener(timelineEventBindings, track, 'pointerup', onTrackPointerUp); addTrackedEventListener(timelineEventBindings, track, 'pointercancel', onTrackPointerCancel); addTrackedEventListener(timelineEventBindings, track, 'keydown', onTrackKeyDown); return { update, sync, jumpTo, jumpToStart, releaseJump, refreshRelativeTimes, setActive, destroy() { if (frame) cancelAnimationFrame(frame); if (previewFrame) cancelAnimationFrame(previewFrame); finishTimelineJumpAnimation(); setActive(false); hidePointerPreview(); if (cursorEl) cursorEl.replaceChildren(); lensFloorElements.clear(); lensTargetPostNumber = 0; if (activePointerId && track.hasPointerCapture && track.hasPointerCapture(activePointerId)) { try { track.releasePointerCapture(activePointerId); } catch (e) {} } dragging = false; activePointerId = 0; removeTrackedEventListeners(timelineEventBindings); }, }; } async function openModal(topicId, options = {}) { const openRequestId = ++READER_OPEN_REQUEST_SEQUENCE; const performance = Object.freeze(currentPerformanceConfig()); let targetPostNumber = +options.targetPostNumber || 0; let unavailableTargetPostNumber = 0; const initialQuoteHighlight = options.quoteHighlight && typeof options.quoteHighlight.text === 'string' ? { text: options.quoteHighlight.text, source: options.quoteHighlight.source || null } : null; const initialQuoteSourceTarget = options.quoteSourceTarget && typeof options.quoteSourceTarget === 'object' ? options.quoteSourceTarget : null; let restoreViewport = options.restoreViewport && typeof options.restoreViewport === 'object' ? options.restoreViewport : null; const restoreOnlyOp = options.restoreOnlyOp === true; const settleTargetJump = options.settleTargetJump !== false; const directTopicRoute = !!extractTopicRouteFromUrl(location.href); const requestedReaderMode = directTopicRoute ? currentTopicReaderMode() : currentListReaderMode(); const hostTopicPointerAnchor = options.hostTopicPointerAnchor || null; const cachedTopicPreview = getCachedTopicData(topicId); let sourceTopicNavMetadata = mergeTopicNavMetadata( options.topicNavMetadata || readTopicNavMetadata(null, topicId), cachedTopicNavMetadata(cachedTopicPreview), ); const inheritedHistoryPositions = normalizeReaderHistoryPositions( CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderHistoryNavigation?.positions ); const historyNavigation = createReaderHistoryNavigation(topicId, options.historyNavigation); historyNavigation.positions = { ...inheritedHistoryPositions, ...historyNavigation.positions }; let reusableOverlay = null; if (CURRENT_OVERLAY) { const previousOverlay = CURRENT_OVERLAY; const previousTopicId = Number(previousOverlay.dataset.topicId); const previousViewport = normalizeReaderHistoryViewport( typeof previousOverlay._ldpCaptureHistoryViewport === 'function' ? previousOverlay._ldpCaptureHistoryViewport() : null ); if (previousTopicId > 0 && previousViewport) { historyNavigation.positions[previousTopicId] = previousViewport; } // 列表嵌入态必须保留同一个工作台;宿主压缩、分隔线和右侧面板都属于壳层,不属于单篇帖子。 const canReuseEmbeddedShell = !directTopicRoute && isEmbeddedListReaderMode(requestedReaderMode) && previousOverlay.classList.contains('ldp-reader-embedded') && previousOverlay.dataset.readerShellDestroyed !== '1' && previousOverlay._ldpReaderSession && previousOverlay._ldpReaderSession.state !== 'failed' && typeof previousOverlay._ldpPrepareTopicSwitch === 'function'; if (canReuseEmbeddedShell) { await previousOverlay._ldpPrepareTopicSwitch(); if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE || CURRENT_OVERLAY !== previousOverlay || !previousOverlay.isConnected) return; reusableOverlay = previousOverlay; } else if (typeof previousOverlay._ldpDispose === 'function') await previousOverlay._ldpDispose(); else { if (typeof previousOverlay._ldpStopLatestReplyPolling === 'function') { previousOverlay._ldpStopLatestReplyPolling(); } previousOverlay.remove(); if (CURRENT_OVERLAY === previousOverlay) CURRENT_OVERLAY = null; setPageScrollLocked(false); } } if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return; if (document.documentElement.classList.contains('ldp-reader-sidepanel-active') && !await closeChromeSidePanelForReader()) return; if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return; if (directTopicRoute) document.documentElement.classList.add('ldp-route-takeover'); const readerSessionId = ++READER_SESSION_SEQUENCE; const reusingShell = !!reusableOverlay; const overlay = reusableOverlay || document.createElement('div'); let readerOpenPhase = 'create-overlay'; const setReaderOpenPhase = (phase) => { readerOpenPhase = phase; }; const readerSession = { id: readerSessionId, state: 'opening', isCurrent(...allowedStates) { if (ACTIVE_READER_SESSION_ID !== readerSessionId || CURRENT_OVERLAY !== overlay || !overlay.isConnected) { return false; } return !allowedStates.length || allowedStates.includes(this.state); }, }; ACTIVE_READER_SESSION_ID = readerSessionId; if (!reusingShell) { overlay.className = `ldp-overlay${(directTopicRoute && PREFS.fullPage) || (!directTopicRoute && requestedReaderMode === 'fullpage') ? ' ldp-fullpage' : ''}`; overlay.dataset.readerWorkspaceMode = 'floating'; overlay.dataset.readerListModeAllowed = String(!directTopicRoute); overlay.innerHTML = ` <div class="ldp-reader-window-capsule"> <button class="ldp-reader-drag-handle" type="button" aria-label="拖动阅读器">${icon('minus')}</button> <button class="ldp-reader-pin-button" type="button" aria-label="置顶浮窗" aria-pressed="false">${icon('pin')}</button> <button class="ldp-reader-lock-button" type="button" aria-label="锁定浮窗" aria-pressed="false">${icon('unlock')}</button> <div class="ldp-reader-placement-control" hidden> <button class="ldp-reader-placement-trigger" type="button" aria-label="显示方式" aria-haspopup="true">${icon('layoutGrid')}</button> <div class="ldp-reader-placement-strip" role="group" aria-label="切换阅读器显示方式" hidden> <button class="ldp-reader-placement-option" type="button" data-reader-placement="embed-left" data-reader-placement-label="嵌入左侧" aria-label="嵌入左侧">${icon('panelLeft')}</button> <button class="ldp-reader-placement-option" type="button" data-reader-placement="embed-right" data-reader-placement-label="嵌入右侧" aria-label="嵌入右侧">${icon('panelRight')}</button> <button class="ldp-reader-placement-option" type="button" data-reader-placement="floating" data-reader-placement-label="浮窗阅读器" aria-label="浮窗阅读器">${icon('floatingWindow')}</button> <button class="ldp-reader-placement-option" type="button" data-reader-placement="fullpage" data-reader-placement-label="全屏阅读器" aria-label="全屏阅读器">${icon('maximize2')}</button> </div> </div> </div> <div class="ldp-reader-host-scrollbar ldp-reader-host-scrollbar-inactive" role="scrollbar" aria-label="Linux DO 主题列表滚动条" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-disabled="true" tabindex="0"> <span class="ldp-reader-host-scrollbar-thumb" aria-hidden="true"></span> </div> <button class="ldp-reader-host-top" type="button" aria-label="回到宿主页面顶部" title="回到宿主页面顶部" hidden>${icon('arrowUp')}<span class="ldp-reader-host-top-countdown" aria-hidden="true">3</span></button> <div class="ldp-modal"> <span class="ldp-reader-embed-resize" aria-hidden="true"></span> ${['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'].map((direction) => `<span class="ldp-reader-resize-handle" data-reader-resize="${direction}" aria-hidden="true"></span>`).join('')} <div class="ldp-header"> <a class="ldp-home-logo" href="${BASE}/" aria-label="回到 LINUX DO 首页"> <img class="ldp-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async"> </a> <div class="ldp-title-wrap"> <h2 class="ldp-title"> <span class="ldp-title-jump" role="button" tabindex="0" aria-label="跳到 #1">正在载入主题…</span> </h2> <div class="ldp-title-subline"> <div class="ldp-meta-row"> <div class="ldp-meta"> <span class="ldp-meta-stats">正在读取主题信息…</span> <span class="ldp-meta-owner" hidden><span class="ldp-meta-owner-copy">楼主 <span class="ldp-meta-owner-value">@?</span></span><button class="ldp-only-op-toggle" type="button" aria-label="只看楼主" aria-pressed="false" disabled>${icon('userRound')}</button><span class="ldp-only-op-progress" role="status" aria-live="polite" hidden><span class="ldp-only-op-progress-track"><i class="ldp-only-op-progress-fill"></i></span><span class="ldp-only-op-progress-value"></span></span></span> </div> </div> <div class="ldp-title-topic-row"> <div class="ldp-topic-tags" hidden></div> <div class="ldp-topic-vote-slot" hidden></div> </div> </div> </div> <div class="ldp-head-btns"> <button class="ldp-layout-toggle" type="button"></button> <button class="ldp-notifications-toggle" type="button" aria-label="消息" aria-expanded="false">${icon('headerBell')}<span>消息</span><b class="ldp-notification-unread-badge" hidden></b></button> <div class="ldp-notifications-popover" hidden> <div class="ldp-notification-tabs" role="tablist" aria-label="消息分类"> ${Object.entries(NOTIFICATION_GROUPS).map(([key, group], index) => `<button class="ldp-notification-tab${index === 0 ? ' active' : ''}" type="button" role="tab" data-notification-group="${key}" aria-selected="${index === 0 ? 'true' : 'false'}">${group.label}</button>`).join('')} </div> <div class="ldp-notification-toolbar"> <span class="ldp-notification-unread-status">没有未读消息</span> <button class="ldp-notification-mark-all" type="button" disabled>${icon('check')}<span>全部已读</span></button> </div> ${popoverSearchMarkup('notification', '搜索用户名或用户 ID', '搜索消息', '清空消息搜索')} <div class="ldp-notification-list"><div class="ldp-notification-empty">正在加载消息…</div></div> ${popoverPagerMarkup('notification')} </div> <button class="ldp-history-toggle" type="button" aria-label="浏览历史" aria-expanded="false">${icon('headerHistory')}<span>历史</span></button> <div class="ldp-history-popover" hidden> <div class="ldp-collection-title"> <span>浏览历史</span> <div class="ldp-collection-title-actions ldp-history-default-actions"> <button class="ldp-collection-action ldp-history-sort-toggle" type="button"></button> <button class="ldp-collection-action ldp-history-multi" type="button" aria-label="多选浏览历史">${icon('listChecks')}</button> <button class="ldp-collection-action danger ldp-history-clear" type="button" aria-label="清空全部浏览历史" disabled>${icon('trash')}</button> </div> ${collectionBulkActionsMarkup('history', '历史', '浏览历史', '删除所选浏览历史')} </div> ${popoverSearchMarkup('history', '搜索标题或拼音', '搜索浏览历史', '清空历史搜索')} <div class="ldp-history-list ldp-notification-list"><div class="ldp-notification-empty">暂无浏览历史</div></div> ${popoverPagerMarkup('history')} </div> <button class="ldp-bookmarks-toggle" type="button" aria-label="收藏与回应" aria-expanded="false">${icon('headerBookmark')}<span>收藏与回应</span></button> <div class="ldp-bookmarks-popover" hidden> <div class="ldp-collection-title"> <span>收藏与回应</span> <div class="ldp-collection-title-actions ldp-bookmarks-default-actions"> <button class="ldp-collection-action ldp-bookmarks-multi" type="button" aria-label="多选收藏">${icon('listChecks')}</button> </div> ${collectionBulkActionsMarkup('bookmarks', '收藏', '收藏', '取消所选收藏')} </div> <div class="ldp-bookmark-tabs" role="tablist" aria-label="收藏与回应类型"> ${PREFS.bookmarkTabOrder.map((type, index) => `<button class="ldp-bookmark-tab${index === 0 ? ' active' : ''}" type="button" role="tab" data-bookmark-type="${type}" data-ldp-tooltip-label="拖动排序 · 首项默认" aria-selected="${String(index === 0)}">${BOOKMARK_TAB_LABELS[type]}</button>`).join('')} </div> ${popoverSearchMarkup('bookmarks', '搜索收藏标题或内容', '搜索收藏', '清空收藏搜索')} <div class="ldp-reaction-filters" role="group" aria-label="按回应表情筛选" hidden></div> <div class="ldp-bookmarks-list ldp-notification-list"><div class="ldp-notification-empty">正在加载收藏…</div></div> ${popoverPagerMarkup('bookmarks')} </div> <button class="ldp-settings-toggle" type="button" aria-label="设置" aria-expanded="false">${icon('headerSettings')}<span>设置</span></button> <div class="ldp-settings-popover" hidden> <div class="ldp-settings-tabs"> <div class="ldp-settings-brand" aria-label="awesome linuxdo reader"> <img class="ldp-settings-brand-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async"> <span class="ldp-settings-brand-name"><span>AWESOME</span><span>LINUXDO</span><span>READER</span></span> </div> <div class="ldp-settings-nav-shell"> <button class="ldp-settings-nav-scroll ldp-settings-nav-scroll-up" type="button" aria-label="显示上方更多设置" hidden>${icon('chevronRight')}</button> <div class="ldp-settings-nav"> ${settingsNavigationMarkup()} </div> <button class="ldp-settings-nav-scroll ldp-settings-nav-scroll-down" type="button" aria-label="显示下方更多设置" hidden>${icon('chevronRight')}</button> </div> <div class="ldp-settings-footer"> <div class="ldp-settings-theme" role="group" aria-label="明暗模式"> ${READER_THEME_MODES.map((mode) => `<button class="ldp-settings-theme-button" type="button" data-reader-theme-mode="${mode}" aria-pressed="false">${icon(readerThemeIconName(mode))}</button>`).join('')} </div> </div> </div> <div class="ldp-settings-panel"> <div class="ldp-settings-section" data-settings-panel="user"> ${settingsPanelIntroMarkup('user')} <div class="ldp-user-info-content" aria-live="polite"> <div class="ldp-user-info-loading">正在加载用户信息…</div> </div> </div> <div class="ldp-settings-section" data-settings-panel="image" hidden> ${settingsPanelIntroMarkup('image')} <div class="ldp-settings-category-groups"> ${settingsCategoryMarkup( 'image-lightbox', '灯箱行为', '控制灯箱打开后的评论、描述和原图加载方式。', settingSwitchesMarkup('image-lightbox'), 'ldp-settings-fields ldp-settings-category-list' )} ${settingsCategoryMarkup( 'image-display', '帖子图片显示', '按阅读器形态调整帖子中的图片比例,也可让三种形态共享同一设置。', ` ${readerProfileTabsMarkup('image', '图片')} <div class="ldp-settings-fields"> <div class="ldp-setting-row ldp-setting-row-has-sharing"> <span class="ldp-setting-label">图片比例</span> <span class="ldp-image-scale-control"> <input class="ldp-image-scale-range" type="range" min="${IMAGE_SCALE_MIN}" max="${IMAGE_SCALE_MAX}" step="1" aria-label="图片比例"> <span class="ldp-image-scale-value"></span> <button class="ldp-color-reset ldp-image-scale-reset" type="button">${icon('rotateCcw')}<span>默认</span></button> </span> ${profileFieldSharingControl('image', 'scale', '图片比例')} </div> </div> `)} </div> </div> <div class="ldp-settings-section" data-settings-panel="font" hidden> ${settingsPanelIntroMarkup('font')} <div class="ldp-settings-category-groups"> ${settingsCategoryMarkup( 'font-rendering', '字体渲染', '控制增强阅读器及 LINUX DO 宿主页面是否使用内置字体渲染。', settingSwitchesMarkup('font-rendering'), 'ldp-settings-fields ldp-settings-category-list ldp-font-rendering-settings' )} ${settingsCategoryMarkup( 'host-font', '宿主主题列表文字', '标题、Label Card 和右下角信息共享字体、字重和颜色;修改后立即保存。', fontFieldRowsMarkup('host', HOST_FONT_CONFIG), 'ldp-settings-fields ldp-settings-category-list ldp-host-font-settings' )} ${settingsCategoryMarkup( 'host-embed-size', '宿主嵌入态字号', '分别调整阅读器嵌入 LINUX DO 页面时,原帖、主题列表和通知区域的显示尺寸。', ` ${Object.entries(HOST_EMBED_SIZE_SETTINGS).map(([key, config]) => ` <div class="ldp-setting-row"> <span class="ldp-setting-label">${config.label}</span> <span class="ldp-host-embed-size-control"> <input type="range" data-host-embed-size="${key}" min="${HOST_EMBED_SIZE_MIN}" max="${HOST_EMBED_SIZE_MAX}" step="1" aria-label="宿主嵌入态${config.label}大小"> <output class="ldp-host-embed-size-value" data-host-embed-size-value="${key}"></output> <button class="ldp-color-reset ldp-host-embed-size-reset" type="button" data-host-embed-size-reset="${key}">${icon('rotateCcw')}<span>默认</span></button> </span> </div>`).join('')} `, 'ldp-settings-fields ldp-settings-category-list ldp-host-embed-size-settings')} ${settingsCategoryMarkup( 'reader-font', '阅读器字体', '按显示形态分别设置界面、正文和回复字体,也可逐项共享。', ` ${readerProfileTabsMarkup('font', '字体')} <div class="ldp-layout-profile-tabs ldp-font-scope-tabs" role="tablist" aria-label="字体作用范围"> ${FONT_SCOPE_KEYS.map((scope) => `<button class="ldp-layout-profile-tab${scope === 'interface' ? ' active' : ''}" id="ldp-font-scope-tab-${scope}" type="button" role="tab" data-font-scope-tab="${scope}" aria-controls="ldp-font-scope-panel-${scope}" aria-selected="${scope === 'interface'}" tabindex="${scope === 'interface' ? '0' : '-1'}">${FONT_SCOPE_CONFIG[scope].label}</button>`).join('')} </div> <div class="ldp-settings-fields ldp-font-settings-fields"> ${FONT_SCOPE_KEYS.map(fontScopeSettingsMarkup).join('')} </div> `)} </div> <div class="ldp-font-family-menu" id="ldp-font-family-menu" hidden> <label class="ldp-font-family-search ldp-picker-search"> ${icon('search')} <input type="search" autocomplete="off" spellcheck="false" placeholder="搜索字体名称" aria-label="搜索字体" aria-controls="ldp-font-family-options"> </label> <p class="ldp-font-family-source-status" role="status" aria-live="polite"></p> <div class="ldp-font-family-options ldp-picker-options" id="ldp-font-family-options" role="listbox" aria-label="可用字体"></div> <div class="ldp-font-family-empty" hidden>没有匹配的字体</div> <div class="ldp-font-family-manual"><span>列表里没有?</span><button type="button">手动输入字体名</button></div> </div> ${settingsApplyFooterMarkup('ldp-font-status', 'ldp-font-apply', '全部恢复默认', 'ldp-font-reset')} </div> <div class="ldp-settings-section" data-settings-panel="layout" hidden> ${settingsPanelIntroMarkup('layout')} <div class="ldp-settings-category-groups"> <div class="ldp-settings-category-group"> <div class="ldp-settings-category-content"> ${readerProfileTabsMarkup('layout', '布局')} <div class="ldp-settings-fields ldp-layout-fields"> ${LAYOUT_REGION_KEYS.map((name) => `<div class="ldp-setting-row ldp-setting-row-has-sharing"> <span class="ldp-setting-label">${LAYOUT_REGION_LABELS[name]}</span> <span class="ldp-layout-ratio-control"> <input type="range" data-layout-region="${name}" min="0" max="100" step="0.1" aria-valuemin="${LAYOUT_MIN_RATIOS[name]}" aria-label="${LAYOUT_REGION_LABELS[name]}比例"> <span class="ldp-layout-ratio-value" data-layout-value="${name}"></span> </span> ${profileFieldSharingControl('layout', name, `${LAYOUT_REGION_LABELS[name]}比例`)} </div>`).join('')} </div> </div> </div> </div> ${settingsApplyFooterMarkup('', '', '恢复默认')} </div> <div class="ldp-settings-section" data-settings-panel="window" hidden> ${settingsPanelIntroMarkup('window')} <div class="ldp-settings-category-groups"> ${settingsCategoryMarkup( 'reader-window-geometry', '尺寸与位置', '设置浮窗宽高和左上角坐标,拖动或缩放浮窗后会同步更新。', ` <div class="ldp-reader-window-fields">${readerWindowGeometryMarkup()}</div> `)} ${settingsCategoryMarkup( 'reader-window-behavior', '窗口行为', '控制浮窗离开焦点后的显示状态,以及是否允许拖动和缩放。', ` <label class="ldp-reader-window-option"><input class="ldp-reader-window-pin-input" type="checkbox"><span>置顶浮窗(点击页面其他位置时保持显示)</span></label> <label class="ldp-reader-window-option"><input class="ldp-reader-window-lock-input" type="checkbox"><span>锁定浮窗(锁定后可点小锁或在这里解锁)</span></label> `, 'ldp-settings-category-content ldp-reader-window-options')} </div> <div class="ldp-reader-window-footer"> <span class="ldp-reader-window-status" role="status"></span> <button class="ldp-reader-window-reset" type="button">${icon('rotateCcw')}<span>恢复浮窗默认</span></button> </div> </div> <div class="ldp-settings-section" data-settings-panel="appearance" hidden> ${settingsPanelIntroMarkup('appearance')} ${readerProfileTabsMarkup('appearance', '外观')} <div class="ldp-color-groups">${APPEARANCE_SETTING_GROUPS.map(appearanceSettingGroupMarkup).join('')}</div> ${settingsApplyFooterMarkup('ldp-appearance-status', 'ldp-appearance-apply')} </div> <div class="ldp-settings-section" data-settings-panel="flash" hidden> ${settingsPanelIntroMarkup('flash')} <div class="ldp-settings-category-groups"> <div class="ldp-settings-category-group"> <div class="ldp-settings-category-content"> <div class="ldp-settings-fields ldp-flash-fields">${jumpHighlightSettingsMarkup()}</div> </div> </div> </div> ${settingsApplyFooterMarkup('ldp-flash-status', 'ldp-flash-apply')} <div class="ldp-settings-category-groups"> ${settingsCategoryMarkup( 'loading-motion', '等待区域动画', '选择“每次随机”时,每次打开或切换帖子都会从 10 个系统动效中重新抽取。', ` <label class="ldp-setting-row ldp-motion-choice-row"> <span class="ldp-setting-label">使用动效</span> <select class="ldp-reader-select ldp-loading-animation-select" aria-label="等待区域动效"> <option value="random">每次随机(推荐)</option> ${READER_LOADING_ANIMATIONS.map((animation) => `<option value="${animation.key}">${animation.label}</option>`).join('')} </select> </label> `)} </div> <div class="ldp-loading-settings-preview"> <div class="ldp-loading-settings-preview-head"> <span><strong>页面预览</strong><small class="ldp-loading-settings-preview-label"></small></span> <button class="ldp-loading-preview-reroll" type="button">换一个</button> </div> <div class="ldp-loading-preview-stage" aria-live="polite"></div> </div> <p class="ldp-loading-settings-note">预览与阅读器等待区域共用同一套动效代码;偏好会自动保存在当前浏览器。</p> </div> <div class="ldp-settings-section" data-settings-panel="performance" hidden> ${settingsPanelIntroMarkup('performance')} <div class="ldp-performance-presets" role="group" aria-label="性能预设"> <button class="ldp-performance-preset" type="button" data-performance-preset="low">省资源</button> <button class="ldp-performance-preset" type="button" data-performance-preset="balanced">均衡</button> <button class="ldp-performance-preset" type="button" data-performance-preset="high">低等待</button> <button class="ldp-performance-preset" type="button" data-performance-preset="custom">自定义</button> </div> <div class="ldp-settings-category-groups"> ${performanceSettingsMarkup()} </div> <div class="ldp-performance-footer"> <span class="ldp-performance-status" role="status"></span> <button class="ldp-performance-reset" type="button">${icon('rotateCcw')}<span>恢复默认</span></button> </div> </div> <div class="ldp-settings-section" data-settings-panel="resource-monitor" hidden> ${settingsPanelIntroMarkup('resource-monitor')} <div class="ldp-resource-monitor-health" data-level="normal" role="status"> <strong class="ldp-resource-monitor-health-state">建立基线</strong> <p class="ldp-resource-monitor-health-detail">连续取得 10 个真实快照后,开始判断 DOM 增长、共享主线程长任务和阅读器请求排队。</p> <span class="ldp-resource-monitor-updated">等待采样</span> </div> <section class="ldp-resource-monitor-evidence" aria-labelledby="ldp-resource-monitor-evidence-title"> <div class="ldp-resource-monitor-evidence-head"><strong id="ldp-resource-monitor-evidence-title">最近 60 秒前后台实测</strong><span class="ldp-resource-monitor-evidence-window">等待事件</span></div> <div class="ldp-resource-monitor-scope-table" role="table" aria-label="阅读器与宿主前后台资源证据"> <div class="ldp-resource-monitor-scope-head" role="row"><span>范围</span><span>当前结构</span><span>前台事件</span><span>后台事件</span></div> <div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="reader"><strong>阅读器</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div> <div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="host"><strong>宿主 / 未标记</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div> <div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="shared"><strong>页面共享</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div> </div> </section> <div class="ldp-resource-monitor-table" role="table" aria-label="阅读器实时资源数据"> <div class="ldp-resource-monitor-table-head" role="row"><span>指标</span><span>当前</span><span>样本均</span><span>观测峰</span><span>首末变化</span></div> ${RESOURCE_MONITOR_ROWS.map(resourceMonitorRowMarkup).join('')} </div> <section class="ldp-resource-monitor-trends" aria-labelledby="ldp-resource-monitor-trend-title"> <div class="ldp-resource-monitor-trend-head"><strong id="ldp-resource-monitor-trend-title">最近 10 分钟趋势</strong><span class="ldp-resource-monitor-trend-window">等待采样</span></div> <div class="ldp-resource-monitor-trend-list"> <div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="heapUsed"><span>页面内存估计</span><strong data-resource-monitor-chart-current="heapUsed">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div> <div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="dom"><span>阅读器 DOM</span><strong data-resource-monitor-chart-current="dom">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div> <div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="retainedFloors"><span>保留主楼层</span><strong data-resource-monitor-chart-current="retainedFloors">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div> </div> </section> <section class="ldp-resource-monitor-events" aria-labelledby="ldp-resource-monitor-events-title"> <div class="ldp-resource-monitor-events-head"><strong id="ldp-resource-monitor-events-title">毫秒级事件记录</strong><span>时间 · 前后台 · 范围 · 原始事件</span></div> <div class="ldp-resource-monitor-event-log" role="log" aria-live="off"><div class="ldp-resource-monitor-event-empty">等待 Performance Timeline、Resource Timing、DOM 或可见性事件。</div></div> </section> <p class="ldp-resource-monitor-boundary">“前台 / 后台”严格取自 Page Visibility,网络与性能条目按开始时状态归档;MutationRecord 没有原生时间戳,DOM 变更只能按观察器回调时状态记录。阅读器请求以显式元数据归因;未标记的页面 fetch/XHR 只能记为“宿主 / 未标记”;浏览器资源、垃圾回收、样式布局及无法确认脚本来源的长任务保留在“页面共享”。浏览器不提供单个脚本的真实总 CPU 或独占内存,本页不会用推算值冒充。跨源隔离不可用时不读取已弃用且不可靠的 performance.memory。</p> </div> <div class="ldp-settings-section" data-settings-panel="request-flow" hidden> ${settingsPanelIntroMarkup('request-flow')} <div class="ldp-request-flow-summary" aria-label="请求速率摘要"> <div class="ldp-request-flow-metric"><span>本页 10 秒</span><strong data-request-flow-metric="rate10">0 次</strong></div> <div class="ldp-request-flow-metric"><span>本页 60 秒</span><strong data-request-flow-metric="rate60">0 次</strong></div> <div class="ldp-request-flow-metric"><span>本页 100ms 峰值</span><strong data-request-flow-metric="peak">0 次</strong></div> <div class="ldp-request-flow-metric"><span>本页 60 秒传输</span><strong data-request-flow-metric="transfer">0 B</strong></div> <div class="ldp-request-flow-metric"><span>本页 60 秒异常</span><strong data-request-flow-metric="issues">0 次</strong></div> </div> <section class="ldp-request-flow-block ldp-request-flow-chart-block" aria-labelledby="ldp-request-flow-timeline-title"> <div class="ldp-request-flow-block-head"> <strong id="ldp-request-flow-timeline-title">毫秒请求脉络</strong> <span class="ldp-request-flow-window">等待采样</span> </div> <div class="ldp-request-flow-trace" role="img" aria-label="最近 10 秒按来源显示排队、网络耗时与异常点"></div> <div class="ldp-request-flow-axis"><span>10 秒前</span><span>虚线排队 · 短线放行 · 实色网络 · 圆点异常</span><span>现在</span></div> <div class="ldp-request-flow-legend" aria-label="请求类型图例"></div> </section> <div class="ldp-request-flow-bottleneck" data-level="normal" role="status"> <strong class="ldp-request-flow-bottleneck-state">采样中</strong> <p class="ldp-request-flow-bottleneck-detail">打开帖子并正常滚动后,这里会判断限流、排队、响应延迟或静态资源是否成为主要瓶颈。</p> </div> <section class="ldp-request-flow-block ldp-request-flow-anomaly-block" aria-labelledby="ldp-request-flow-anomaly-title"> <div class="ldp-request-flow-block-head"><strong id="ldp-request-flow-anomaly-title">最近异常点</strong><span class="ldp-request-flow-anomaly-window">最近 60 秒</span></div> <div class="ldp-request-flow-anomalies"></div> </section> <section class="ldp-request-flow-block ldp-request-flow-types-block" aria-labelledby="ldp-request-flow-types-title"> <div class="ldp-request-flow-type-head"><strong id="ldp-request-flow-types-title">最近 60 秒按类型</strong><span>数量</span><span>P95</span><span>异常</span></div> <div class="ldp-request-flow-types"></div> </section> <section class="ldp-request-flow-block ldp-request-flow-log-block" aria-labelledby="ldp-request-flow-log-title"> <div class="ldp-request-flow-block-head"><strong id="ldp-request-flow-log-title">毫秒请求记录</strong><span>路径与发起点已脱敏,不保存查询参数</span></div> <div class="ldp-request-flow-log" role="log" aria-live="off"></div> </section> <section class="ldp-request-flow-limit" aria-labelledby="ldp-request-flow-limit-title"> <h4 id="ldp-request-flow-limit-title">LINUX DO 可能的请求上限</h4> <p class="ldp-request-flow-observed">当前还没有观察到服务器限流信息。</p> <p><strong>Discourse 公开默认:</strong>应用请求约 50 次/10 秒、200 次/分钟;头像、CSS 等静态资源约 200 次/10 秒。LINUX DO 可以覆盖这些数字,搜索、发帖、私信、插件和反向代理也可能有更低的独立限制。</p> <p>因此这里只作为风险刻度;真实边界以本页收到的 <strong>429、Retry-After 和限流错误码</strong>为准。<a href="https://meta.discourse.org/t/available-settings-for-global-rate-limits-and-throttling/78612" target="_blank" rel="noopener">查看 Discourse 公开说明</a></p> </section> </div> <div class="ldp-settings-section" data-settings-panel="other" hidden> ${settingsPanelIntroMarkup('other')} <div class="ldp-settings-fields ldp-other-settings-fields"> ${otherSettingGroupMarkup('history', '历史导航', '控制历史前进、后退按钮的显示方式与历史列表顺序。', ` ${settingSwitchesMarkup('history')} <label class="ldp-setting-row ldp-setting-option-row ldp-history-edge-trigger-row"> <span class="ldp-setting-option-copy"> <strong>左右按钮触发区域</strong> <small>鼠标进入阅读器左右边缘后显示对应按钮;0% 为关闭,左右两侧各自最大 15%。</small> </span> <span class="ldp-history-edge-trigger-control"> <input class="ldp-history-edge-trigger-range" type="range" min="${HISTORY_EDGE_TRIGGER_MIN}" max="${HISTORY_EDGE_TRIGGER_MAX}" step="1" aria-label="左右按钮触发区域"> <output class="ldp-history-edge-trigger-value"></output> </span> </label> <label class="ldp-setting-row ldp-setting-option-row"> <span class="ldp-setting-option-copy"> <strong>浏览历史排序</strong> <small>默认按最后一次打开时间倒序,距离现在越近越靠前;也可固定为首次点击顺序。</small> </span> <select class="ldp-reader-select ldp-history-sort-mode" aria-label="浏览历史排序规则"> <option value="recent-viewed">最近点击(默认)</option> <option value="first-viewed">首次点击(固定)</option> </select> </label> `)} ${otherSettingGroupMarkup('topic-opening', '帖子打开', '决定从普通帖子链接进入阅读器时采用的起始楼层。', ` ${settingSwitchesMarkup('topic-opening')} `)} ${otherSettingGroupMarkup('replies', '回复展示', '控制二级回复在父楼层和主信息流中的默认展开位置。', ` ${settingSwitchesMarkup('replies')} <p class="ldp-nested-display-warning" role="status" aria-live="polite" hidden>两种方式同时开启时,同一条二级回复可能会在父楼层下和主信息流中重复出现,阅读时可能看到重复信息。</p> `)} ${otherSettingGroupMarkup('boost', 'Boost 复制', '复制结果 = 开头文字 + 原 Boost + 小尾巴;不用填写正则。', ` ${boostCopySettingsMarkup()} <div class="ldp-setting-row ldp-boost-rule-row"> <span class="ldp-setting-label">结果预览</span> <span class="ldp-boost-rule-preview"> <code class="ldp-boost-copy-preview"></code> <small>最多 16 字</small> </span> </div> `)} </div> </div> <div class="ldp-settings-section" data-settings-panel="cache" hidden> ${settingsPanelIntroMarkup('cache')} <div class="ldp-settings-category-groups"> ${settingsCategoryMarkup( 'config-management', '设置配置', '配置文件包含当前开发版全部规范化设置(含图片与灯箱、字体、布局、浮窗、外观、闪烁、性能、主题及其他功能)和脚本版本,不包含浏览历史、帖子内容、API 响应、图片缓存或账号数据。只接受当前格式;导入与恢复后会刷新页面。', ` <div class="ldp-config-actions"> <button class="ldp-config-action ldp-config-export" type="button">${icon('download')}<span>导出设置</span></button> <button class="ldp-config-action ldp-config-import" type="button">${icon('upload')}<span>导入设置</span></button> <button class="ldp-config-action ldp-config-reset danger" type="button">${icon('rotateCcw')}<span>恢复全部默认</span></button> </div> <input class="ldp-config-file" type="file" accept="application/json,.json" hidden> `)} ${settingsCategoryMarkup( 'local-cache', '本地缓存', '只保存可复用的结构化数据,并按浏览记录、主题快照、楼层正文、用户资料、消息分页和通用 API 分组管理;头像、表情和查看过的原图使用独立资源缓存。各项统计会区分业务对象与底层记录,并对重复对象去重。新鲜数据直接读取,过期后联网校准,网络异常时可回退到保留期内的数据。', ` <div class="ldp-cache-list"> ${CACHE_TYPES.map((type) => { return `<label class="ldp-cache-row"> <input class="ldp-cache-select" type="checkbox" value="${type.key}"> <span><strong>${type.label}</strong><small data-cache-size="${type.key}"></small></span> <em data-cache-retention="${type.key}">${cacheRetentionLabel(type.key)}</em> </label>`; }).join('')} </div> <button class="ldp-cache-clear" type="button" disabled>清理已选缓存</button> `, 'ldp-settings-category-content', 'ldp-cache-note')} </div> </div> <div class="ldp-settings-section" data-settings-panel="about" hidden> ${settingsPanelIntroMarkup('about')} <div class="ldp-about-content"> <section class="ldp-about-hero" aria-labelledby="ldp-about-name"> <img class="ldp-about-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async"> <div class="ldp-about-identity"> <h4 class="ldp-about-name" id="ldp-about-name">awesome linuxdo reader</h4> <p class="ldp-about-tagline">在原站能力之上,提供更连贯、更可控的阅读体验。</p> </div> <span class="ldp-about-version">v${READER_VERSION}</span> </section> <div class="ldp-about-features" aria-label="阅读器核心特性">${aboutFeaturesMarkup()}</div> <section class="ldp-about-credits" aria-labelledby="ldp-about-credits-title"> <strong id="ldp-about-credits-title">特别致谢</strong> <p>字体渲染参数与实现思路参考 <a href="${FONT_RENDERING_PROJECT_URL}" target="_blank" rel="noopener">F9y4ng / GreasyFork-Scripts 的 Font Rendering</a>;感谢作者的长期维护。上游项目采用 <a href="${FONT_RENDERING_LICENSE_URL}" target="_blank" rel="noopener">GPL-3.0-only</a>。</p> </section> </div> </div> </div> <button class="ldp-settings-close" type="button" aria-label="关闭设置">${icon('x')}</button> </div> </div> <div class="ldp-title-actions"> <button class="ldp-topic-edit-trigger" type="button" aria-label="编辑帖子标题、类别和 label" aria-haspopup="dialog" aria-expanded="false" hidden>${icon('pencil')}</button> <button class="ldp-reader-refresh ldp-icon-btn" type="button" aria-label="清除当前帖子缓存并刷新" disabled>${icon('rotateCcw')}</button> <a class="ldp-open" href="#" target="_blank" rel="noopener" aria-label="打开原帖" hidden>${icon('externalLink')}</a> <button class="ldp-close ldp-icon-btn" type="button" aria-label="关闭">${icon('x')}</button> </div> </div> <div class="ldp-reader-action-layer ldp-topic-edit-layer" hidden> <section class="ldp-reader-action-dialog ldp-topic-edit-dialog" role="dialog" aria-modal="true" aria-labelledby="ldp-topic-edit-dialog-title"> <div class="ldp-reader-action-head"> <strong id="ldp-topic-edit-dialog-title">编辑帖子信息</strong> <button class="ldp-reader-action-close ldp-topic-edit-close" type="button" aria-label="关闭编辑弹层">${icon('x')}</button> </div> <form class="ldp-reader-action-form ldp-topic-edit-form"> <div class="ldp-reader-action-body ldp-topic-edit-body"> <label class="ldp-topic-edit-field ldp-topic-edit-title"> <span>标题</span> <input name="title" type="text" maxlength="255" autocomplete="off" required> </label> <div class="ldp-topic-edit-field"> <span id="ldp-topic-edit-category-label">类别</span> <div class="ldp-topic-edit-category"> <button class="ldp-topic-edit-category-trigger ldp-picker-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="ldp-topic-edit-category-label ldp-topic-edit-category-value" disabled> <span id="ldp-topic-edit-category-value" class="ldp-topic-edit-category-value">正在加载…</span> ${icon('chevronDown')} </button> <div class="ldp-topic-edit-category-menu" hidden> <label class="ldp-topic-edit-category-search ldp-picker-search"> ${icon('search')} <input type="search" autocomplete="off" spellcheck="false" placeholder="搜索类别" aria-label="搜索类别"> </label> <div class="ldp-topic-edit-category-options ldp-picker-options" role="listbox" aria-labelledby="ldp-topic-edit-category-label"></div> </div> </div> </div> <label class="ldp-topic-edit-field"> <span>Label</span> <span class="ldp-topic-edit-label-control"> <span class="ldp-topic-edit-label-values"></span> <input class="ldp-topic-edit-label-input" type="text" autocomplete="off" list="ldp-topic-edit-label-options" placeholder="搜索 label" aria-label="添加 label" disabled> </span> <datalist id="ldp-topic-edit-label-options" class="ldp-topic-edit-label-options"></datalist> </label> <p class="ldp-topic-edit-status" role="status" aria-live="polite"></p> </div> <div class="ldp-reader-action-footer ldp-topic-edit-actions"> <button class="ldp-topic-edit-save" type="submit" aria-label="保存帖子信息">${icon('check')}</button> <button class="ldp-topic-edit-cancel" type="button" aria-label="取消编辑">${icon('x')}</button> </div> </form> </section> </div> <div class="ldp-reader-history-edge ldp-reader-history-edge-back" hidden> <button class="ldp-reader-history-nav ldp-reader-history-back" type="button" hidden>${icon('chevronRight')}</button> </div> <div class="ldp-reader-main"> <div class="ldp-rate-limit-notice" role="status" aria-live="polite" aria-atomic="true" hidden> <span class="ldp-rate-limit-icon" aria-hidden="true">${icon('alertTriangle')}</span> <span class="ldp-rate-limit-copy"> <strong>请求已暂停</strong> <span class="ldp-rate-limit-detail">触发 429,冷却结束后将自动打开过盾浮窗。</span> </span> <a class="ldp-rate-limit-challenge" href="${BASE}/" target="_blank" rel="noopener" aria-label="在独立浮窗打开 LINUX DO 官网完成 Cloudflare 验证">${icon('externalLink')}<span>Cloudflare 验证</span></a> </div> <div class="ldp-body"> <div class="ldp-loadmask">${createReaderLoadingHTML()}</div> <div class="ldp-comments"><div class="ldp-comments-empty" style="display:none">暂无评论</div></div> <div class="ldp-comments-header" hidden>${icon('messageSquare')}<span>评论</span><span class="ldp-comments-count" aria-live="polite"></span></div> <div class="ldp-loading-tip">${icon('loader', 'ldp-tip-icon')}<span>正在加载楼层…</span></div> <div class="ldp-end-tip">已经到底了~</div> <div class="ldp-sentinel"></div> </div> <aside class="ldp-topic-timeline" hidden aria-label="帖子时间轴"> <button class="ldp-topic-timeline-date" type="button" aria-label="跳到首帖"></button> <button class="ldp-topic-timeline-track" type="button" role="slider" aria-label="跳转楼层" aria-orientation="vertical"> <span class="ldp-topic-timeline-cursor" aria-hidden="true"></span> <span class="ldp-topic-timeline-thumb"></span> <strong class="ldp-topic-timeline-count"><span class="ldp-topic-timeline-current">1</span><span>/</span><span class="ldp-topic-timeline-total">1</span></strong> <span class="ldp-topic-timeline-preview" aria-hidden="true"></span> </button> <div class="ldp-topic-timeline-footer"> <button class="ldp-topic-timeline-relative" type="button" aria-label="跳到最后一楼"></button> <button class="ldp-topic-timeline-top" type="button" aria-label="回到顶部,第 1 楼">${icon('arrowUp')}</button> </div> </aside> </div> <div class="ldp-reader-history-edge ldp-reader-history-edge-forward" hidden> <button class="ldp-reader-history-nav ldp-reader-history-forward" type="button" hidden>${icon('chevronRight')}</button> </div> <div class="ldp-live-update" hidden aria-live="polite"> <button class="ldp-live-update-jump" type="button">${icon('messageSquare')}<span></span></button> <button class="ldp-live-update-dismiss" type="button" aria-label="暂时关闭新消息提示">${icon('x')}</button> </div> <div class="ldp-setting-help-tooltip ldp-transient-surface" role="tooltip" hidden></div> </div>`; appendReaderPortalNode(overlay); ensureReaderIconTooltip(); } overlay.dataset.topicId = String(topicId); CURRENT_OVERLAY = overlay; syncReaderFontRenderingState(); overlay._ldpReaderHistoryNavigation = historyNavigation; const disposeHistoryNavigation = bindReaderHistoryNavigation(overlay, topicId, historyNavigation); overlay._ldpReaderSession = readerSession; document.documentElement.classList.add('ldp-reader-open'); overlay.dataset.targetPostNumber = targetPostNumber ? String(targetPostNumber) : ''; overlay.classList.toggle('ldp-initial-target-layout', targetPostNumber > 1); const modal = overlay.querySelector('.ldp-modal'), body = overlay.querySelector('.ldp-body'); if (reusingShell) resetReaderTopicShell(overlay); const readerShell = reusingShell ? overlay._ldpReaderShell : {}; const shouldRestoreHostTopicAnchor = !!hostTopicPointerAnchor && !directTopicRoute && isEmbeddedListReaderMode(requestedReaderMode); if (shouldRestoreHostTopicAnchor) document.documentElement.classList.add('ldp-reader-host-anchor-restoring'); const readerWorkspace = readerShell.readerWorkspace || createReaderWorkspaceController(overlay, modal, directTopicRoute); if (shouldRestoreHostTopicAnchor) { if (readerWorkspace.isEmbedded()) restoreHostTopicPointerAnchor(hostTopicPointerAnchor); requestAnimationFrame(() => document.documentElement.classList.remove('ldp-reader-host-anchor-restoring')); } const initialFullPage = directTopicRoute ? PREFS.fullPage : readerWorkspace.mode() === 'fullpage'; overlay.classList.toggle('ldp-fullpage', initialFullPage); setPageScrollLocked(initialFullPage || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH); const readerCapsule = overlay.querySelector('.ldp-reader-window-capsule'); const readerPlacementControl = overlay.querySelector('.ldp-reader-placement-control'); const readerPlacementStrip = overlay.querySelector('.ldp-reader-placement-strip'); const readerPlacementOptions = overlay.querySelectorAll('[data-reader-placement]'); const commentsEl = overlay.querySelector('.ldp-comments'); const commentsHeader = overlay.querySelector('.ldp-comments-header'); const countEl = overlay.querySelector('.ldp-comments-count'), emptyEl = overlay.querySelector('.ldp-comments-empty'); const maskEl = overlay.querySelector('.ldp-loadmask'); const loadingStage = maskEl && maskEl.querySelector('.ldp-loading-stage'); const loadingStatus = maskEl && maskEl.querySelector('.ldp-loading-status span'); const loadingTarget = maskEl && maskEl.querySelector('.ldp-loading-target'); const loadingDetail = maskEl && maskEl.querySelector('.ldp-loading-detail'); const loadingTip = overlay.querySelector('.ldp-loading-tip'); const loadingTipText = loadingTip && loadingTip.querySelector('span'); const syncLoadingState = (state, options = {}) => { const isTarget = options.target === true && targetPostNumber > 1; const cachedCount = Math.max(0, Number(options.cachedCount) || 0); const missingCount = Math.max(0, Number(options.missingCount) || 0); const copy = state === 'cache' ? { status: isTarget ? '正在读取目标楼层缓存' : '正在读取帖子缓存', detail: cachedCount ? `已读取 ${cachedCount} 条缓存,正在恢复楼层…` : '正在恢复已缓存楼层…', } : state === 'network' ? { status: isTarget ? '正在请求目标楼层' : cachedCount ? '正在补全帖子数据' : '正在请求帖子数据', detail: cachedCount ? `已读取 ${cachedCount} 条缓存,正在下载 ${missingCount} 条缺失楼层…` : missingCount ? `正在下载 ${missingCount} 条缺失楼层…` : '正在下载缺失楼层…', } : { status: '正在渲染帖子', detail: '正在生成页面…' }; if (loadingStatus) loadingStatus.textContent = copy.status; if (loadingDetail) loadingDetail.textContent = copy.detail; if (loadingTipText) loadingTipText.textContent = copy.detail; if (loadingTarget) loadingTarget.textContent = isTarget ? `#${targetPostNumber}` : ''; if (loadingStage) loadingStage.setAttribute('aria-label', `${copy.status},${copy.detail.replace('…', '')}`); }; const cachedTargetAvailable = targetPostNumber <= 1 || !!(cachedTopicPreview && cachedTopicPreview.post_stream && Array.isArray(cachedTopicPreview.post_stream.posts) && cachedTopicPreview.post_stream.posts.some((post) => +post.post_number === targetPostNumber)); syncLoadingState(cachedTopicPreview && cachedTargetAvailable ? 'cache' : 'network', { target: targetPostNumber > 1, }); const endTip = overlay.querySelector('.ldp-end-tip'); const liveUpdateBtn = overlay.querySelector('.ldp-live-update'); const liveUpdateJumpBtn = liveUpdateBtn.querySelector('.ldp-live-update-jump'); const liveUpdateDismissBtn = liveUpdateBtn.querySelector('.ldp-live-update-dismiss'); const timelineEl = overlay.querySelector('.ldp-topic-timeline'); const layoutBtn = overlay.querySelector('.ldp-layout-toggle'); const onlyOpBtn = overlay.querySelector('.ldp-only-op-toggle'); const onlyOpProgress = overlay.querySelector('.ldp-only-op-progress'); const onlyOpProgressValue = onlyOpProgress && onlyOpProgress.querySelector('.ldp-only-op-progress-value'); const rateLimitNotice = overlay.querySelector('.ldp-rate-limit-notice'); const rateLimitDetail = rateLimitNotice && rateLimitNotice.querySelector('.ldp-rate-limit-detail'); const rateLimitChallenge = rateLimitNotice && rateLimitNotice.querySelector('.ldp-rate-limit-challenge'); const notificationsBtn = overlay.querySelector('.ldp-notifications-toggle'); const notificationsPopover = overlay.querySelector('.ldp-notifications-popover'); const notificationTabs = overlay.querySelectorAll('.ldp-notification-tab'); const queryReaderPanelElements = (prefix, fields) => Object.fromEntries(fields.map((field) => [ field, overlay.querySelector(`.ldp-${prefix}-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`), ])); const notificationPanel = queryReaderPanelElements('notification', [ 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'markAll', ]); const historyPanel = queryReaderPanelElements('history', [ 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions', 'bulkActions', 'sortToggle', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected', 'deleteSelectedLabel', 'clear', ]); const bookmarksPanel = queryReaderPanelElements('bookmarks', [ 'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions', 'bulkActions', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected', 'deleteSelectedLabel', ]); const bookmarksReactionFilters = overlay.querySelector('.ldp-reaction-filters'); const bookmarksTabList = overlay.querySelector('.ldp-bookmark-tabs'); const bookmarksTabs = overlay.querySelectorAll('.ldp-bookmark-tab'); const themeButtons = overlay.querySelectorAll('.ldp-settings-theme-button'); const settingsBtn = overlay.querySelector('.ldp-settings-toggle'); const settingsPopover = overlay.querySelector('.ldp-settings-popover'); const settingsNav = overlay.querySelector('.ldp-settings-nav'); const settingsNavScrollUp = overlay.querySelector('.ldp-settings-nav-scroll-up'); const settingsNavScrollDown = overlay.querySelector('.ldp-settings-nav-scroll-down'); const settingsPanel = overlay.querySelector('.ldp-settings-panel'); const settingsCloseBtn = overlay.querySelector('.ldp-settings-close'); const settingHelpTooltip = overlay.querySelector('.ldp-setting-help-tooltip'); if (notificationsPopover && notificationsPopover.parentElement !== overlay) overlay.append(notificationsPopover); if (historyPanel.popover && historyPanel.popover.parentElement !== overlay) overlay.append(historyPanel.popover); if (bookmarksPanel.popover && bookmarksPanel.popover.parentElement !== overlay) overlay.append(bookmarksPanel.popover); if (settingsPopover && settingsPopover.parentElement !== overlay) overlay.append(settingsPopover); if (settingHelpTooltip && settingHelpTooltip.parentElement !== overlay) overlay.append(settingHelpTooltip); syncReaderThemeSurfaces(); const settingsTabs = overlay.querySelectorAll('.ldp-settings-tab'); const settingsSections = overlay.querySelectorAll('.ldp-settings-section'); const userInfoContent = overlay.querySelector('.ldp-user-info-content'); const userInfoLastRefresh = overlay.querySelector('.ldp-user-info-last-refresh'); const userInfoTitleRefresh = overlay.querySelector('.ldp-user-info-title-refresh'); const profileRoutingNotes = overlay.querySelectorAll('[data-profile-routing]'); const profileSharingInputs = overlay.querySelectorAll('[data-profile-sharing-field]'); const imageProfileTabs = overlay.querySelectorAll('[data-image-profile]'); const imageScaleRange = overlay.querySelector('.ldp-image-scale-range'); const imageScaleValue = overlay.querySelector('.ldp-image-scale-value'); const imageScaleReset = overlay.querySelector('.ldp-image-scale-reset'); const lightboxOriginalByDefaultInput = overlay.querySelector('.ldp-lightbox-original-by-default'); const lightboxCommentsExpandedInput = overlay.querySelector('.ldp-lightbox-comments-expanded'); const lightboxDescriptionExpandedInput = overlay.querySelector('.ldp-lightbox-description-expanded'); const fontFamilyTriggers = overlay.querySelectorAll('.ldp-font-family-trigger'); const fontFamilyMenu = overlay.querySelector('.ldp-font-family-menu'); const fontFamilySearch = overlay.querySelector('.ldp-font-family-search input'); const fontFamilyOptions = overlay.querySelector('.ldp-font-family-options'); const fontFamilyEmpty = overlay.querySelector('.ldp-font-family-empty'); const fontFamilySourceStatus = overlay.querySelector('.ldp-font-family-source-status'); const fontFamilyManualButton = overlay.querySelector('.ldp-font-family-manual button'); const fontFamilyCustomInputs = overlay.querySelectorAll('.ldp-font-family-custom'); if (fontFamilyMenu && fontFamilyMenu.parentElement !== overlay) overlay.append(fontFamilyMenu); const fontWeightSelects = overlay.querySelectorAll('.ldp-font-weight-select'); const fontColorInputs = overlay.querySelectorAll('.ldp-font-color'); const fontColorValues = overlay.querySelectorAll('.ldp-font-color-value'); const interfaceFontScaleRange = overlay.querySelector('.ldp-interface-font-scale-range'); const interfaceFontScaleValue = overlay.querySelector('.ldp-interface-font-scale-value'); const fontScaleRange = overlay.querySelector('.ldp-font-scale-range'); const fontScaleValue = overlay.querySelector('.ldp-font-scale-value'); const composerFontScaleRange = overlay.querySelector('.ldp-composer-font-scale-range'); const composerFontScaleValue = overlay.querySelector('.ldp-composer-font-scale-value'); const hostEmbeddedSizeInputs = overlay.querySelectorAll('[data-host-embed-size]'); const hostEmbeddedSizeValues = overlay.querySelectorAll('[data-host-embed-size-value]'); const hostEmbeddedSizeResetButtons = overlay.querySelectorAll('[data-host-embed-size-reset]'); const fontFieldResetButtons = overlay.querySelectorAll('[data-host-font-reset],[data-font-field-reset]'); const fontProfileTabs = overlay.querySelectorAll('[data-font-profile]'); const fontScopeTabs = overlay.querySelectorAll('[data-font-scope-tab]'); const fontScopePanels = overlay.querySelectorAll('[data-font-scope-panel]'); const fontStatus = overlay.querySelector('.ldp-font-status'); const fontResetBtn = overlay.querySelector('.ldp-font-reset'); const fontApplyBtn = overlay.querySelector('.ldp-font-apply'); const fontRenderingEnabledInput = overlay.querySelector('.ldp-font-rendering-enabled'); const fontRenderingOnHostInput = overlay.querySelector('.ldp-font-rendering-on-host'); const layoutProfileTabs = overlay.querySelectorAll('[data-layout-profile]'); const layoutRatioInputs = overlay.querySelectorAll('[data-layout-region]'); const layoutRatioValues = overlay.querySelectorAll('[data-layout-value]'); const layoutTotal = overlay.querySelector('[data-settings-panel="layout"] .ldp-layout-total'); const layoutResetBtn = overlay.querySelector('[data-settings-panel="layout"] .ldp-layout-reset'); const layoutApplyBtn = overlay.querySelector('[data-settings-panel="layout"] .ldp-layout-apply'); const readerWindowWidthInput = overlay.querySelector('.ldp-reader-window-width'); const readerWindowHeightInput = overlay.querySelector('.ldp-reader-window-height'); const readerWindowXInput = overlay.querySelector('.ldp-reader-window-x'); const readerWindowYInput = overlay.querySelector('.ldp-reader-window-y'); const readerWindowPinInput = overlay.querySelector('.ldp-reader-window-pin-input'); const readerWindowLockInput = overlay.querySelector('.ldp-reader-window-lock-input'); const readerWindowStatus = overlay.querySelector('.ldp-reader-window-status'); const readerWindowResetBtn = overlay.querySelector('.ldp-reader-window-reset'); const appearanceSettingElements = APPEARANCE_SETTING_FIELDS.map((field) => { const className = appearanceSettingClassName(field.key); return Object.assign({}, field, { input: overlay.querySelector(`.${className}${field.range ? '-range' : ''}`), output: overlay.querySelector(`.${className}-value`), }); }); const colorResetButtons = overlay.querySelectorAll('[data-color-reset]'); const appearanceProfileTabs = overlay.querySelectorAll('[data-appearance-profile]'); const appearanceStatus = overlay.querySelector('.ldp-appearance-status'); const appearanceApplyBtn = overlay.querySelector('.ldp-appearance-apply'); const performancePresetButtons = overlay.querySelectorAll('.ldp-performance-preset'); const performanceInputs = overlay.querySelectorAll('[data-performance-key]'); const performanceStatus = overlay.querySelector('.ldp-performance-status'); const performanceResetBtn = overlay.querySelector('.ldp-performance-reset'); const resourceMonitorMetrics = overlay.querySelectorAll('[data-resource-monitor-metric]'); const resourceMonitorAverages = overlay.querySelectorAll('[data-resource-monitor-average]'); const resourceMonitorPeaks = overlay.querySelectorAll('[data-resource-monitor-peak]'); const resourceMonitorTrends = overlay.querySelectorAll('[data-resource-monitor-trend]'); const resourceMonitorValueColumns = [ [resourceMonitorMetrics, 'resourceMonitorMetric', ''], [resourceMonitorAverages, 'resourceMonitorAverage', 'Average'], [resourceMonitorPeaks, 'resourceMonitorPeak', 'Peak'], [resourceMonitorTrends, 'resourceMonitorTrend', 'Trend'], ]; const resourceMonitorChartRows = overlay.querySelectorAll('[data-resource-monitor-chart]'); const resourceMonitorHealth = overlay.querySelector('.ldp-resource-monitor-health'); const resourceMonitorHealthState = overlay.querySelector('.ldp-resource-monitor-health-state'); const resourceMonitorHealthDetail = overlay.querySelector('.ldp-resource-monitor-health-detail'); const resourceMonitorTrendWindow = overlay.querySelector('.ldp-resource-monitor-trend-window'); const resourceMonitorUpdated = overlay.querySelector('.ldp-resource-monitor-updated'); const resourceMonitorEvidenceWindow = overlay.querySelector('.ldp-resource-monitor-evidence-window'); const resourceMonitorScopeRows = overlay.querySelectorAll('[data-resource-monitor-scope]'); const resourceMonitorEventLog = overlay.querySelector('.ldp-resource-monitor-event-log'); const resourceMonitorSection = overlay.querySelector('.ldp-settings-section[data-settings-panel="resource-monitor"]'); const requestFlowMetrics = overlay.querySelectorAll('[data-request-flow-metric]'); const requestFlowWindow = overlay.querySelector('.ldp-request-flow-window'); const requestFlowChart = overlay.querySelector('.ldp-request-flow-trace'); const requestFlowLegend = overlay.querySelector('.ldp-request-flow-legend'); const requestFlowBottleneck = overlay.querySelector('.ldp-request-flow-bottleneck'); const requestFlowBottleneckState = overlay.querySelector('.ldp-request-flow-bottleneck-state'); const requestFlowBottleneckDetail = overlay.querySelector('.ldp-request-flow-bottleneck-detail'); const requestFlowAnomalyWindow = overlay.querySelector('.ldp-request-flow-anomaly-window'); const requestFlowAnomalies = overlay.querySelector('.ldp-request-flow-anomalies'); const requestFlowTypes = overlay.querySelector('.ldp-request-flow-types'); const requestFlowLog = overlay.querySelector('.ldp-request-flow-log'); const requestFlowObserved = overlay.querySelector('.ldp-request-flow-observed'); const requestFlowSection = overlay.querySelector('.ldp-settings-section[data-settings-panel="request-flow"]'); const jumpHighlightControls = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => { const className = `.ldp-flash-${field.key}`; return [field.key, { input: overlay.querySelector(className), output: overlay.querySelector(`${className}-value`), }]; })); const jumpHighlightInputs = JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => jumpHighlightControls[field.key].input); const jumpHighlightStatus = overlay.querySelector('.ldp-flash-status'); const jumpHighlightApplyBtn = overlay.querySelector('.ldp-flash-apply'); const loadingAnimationSelect = overlay.querySelector('.ldp-loading-animation-select'); const loadingAnimationPreview = overlay.querySelector('.ldp-loading-preview-stage'); const loadingAnimationPreviewLabel = overlay.querySelector('.ldp-loading-settings-preview-label'); const loadingAnimationReroll = overlay.querySelector('.ldp-loading-preview-reroll'); const historySortModeSelect = overlay.querySelector('.ldp-history-sort-mode'); const historyButtonsAlwaysVisibleInput = overlay.querySelector('.ldp-history-buttons-always-visible'); const historyEdgeTriggerRange = overlay.querySelector('.ldp-history-edge-trigger-range'); const historyEdgeTriggerValue = overlay.querySelector('.ldp-history-edge-trigger-value'); const openTopicsAtFirstPostInput = overlay.querySelector('.ldp-open-topics-first-post'); const expandNestedRepliesByDefaultInput = overlay.querySelector('.ldp-expand-nested-replies-default'); const expandLeafNestedRepliesInput = overlay.querySelector('.ldp-expand-leaf-nested-replies'); const nestedReplyDisplayWarning = overlay.querySelector('.ldp-nested-display-warning'); const boostCopyModeSelect = overlay.querySelector('.ldp-boost-copy-mode'); const boostCopyPrefixInput = overlay.querySelector('.ldp-boost-copy-prefix'); const boostCopyCounterMarkerInput = overlay.querySelector('.ldp-boost-copy-counter-marker'); const boostCopyCounterStepInput = overlay.querySelector('.ldp-boost-copy-counter-step'); const boostCopyFixedSuffixInput = overlay.querySelector('.ldp-boost-copy-fixed-suffix'); const boostCopyCounterRows = overlay.querySelectorAll('.ldp-boost-counter-row'); const boostCopyTextRows = overlay.querySelectorAll('.ldp-boost-text-row'); const boostCopyPreview = overlay.querySelector('.ldp-boost-copy-preview'); const configExportBtn = overlay.querySelector('.ldp-config-export'); const configImportBtn = overlay.querySelector('.ldp-config-import'); const configResetBtn = overlay.querySelector('.ldp-config-reset'); const configFileInput = overlay.querySelector('.ldp-config-file'); const cacheSelects = overlay.querySelectorAll('.ldp-cache-select'); const cacheSizeEls = overlay.querySelectorAll('[data-cache-size]'); const cacheRetentionEls = overlay.querySelectorAll('[data-cache-retention]'); const cacheClearBtn = overlay.querySelector('.ldp-cache-clear'); const settingsColorInputs = overlay.querySelectorAll('.ldp-color-control input[type="color"]'); const colorPickerPresets = ['#F0FFF0', '#FFFFFF', '#E5E7EB', '#94A3B8', '#475569', '#111827', '#47855F', '#22C55E', '#2563EB', '#7C3AED', '#D97706', '#DC2626']; const colorPickerPopover = document.createElement('div'); colorPickerPopover.className = 'ldp-color-picker-popover'; colorPickerPopover.setAttribute('role', 'dialog'); colorPickerPopover.setAttribute('aria-modal', 'false'); colorPickerPopover.setAttribute('aria-label', '选择颜色'); colorPickerPopover.hidden = true; colorPickerPopover.innerHTML = ` <div class="ldp-color-picker-title">选择颜色</div> <div class="ldp-color-picker-presets" role="group" aria-label="常用颜色"> ${colorPickerPresets.map((color) => `<button class="ldp-color-picker-preset" type="button" data-color="${color}" style="--ldp-color-picker-preset:${color}" aria-label="使用颜色 ${color}" aria-pressed="false"></button>`).join('')} </div> <div class="ldp-color-picker-fields"> <input class="ldp-color-picker-hex" type="text" inputmode="text" maxlength="7" autocomplete="off" spellcheck="false" aria-label="十六进制颜色" placeholder="#RRGGBB"> <button class="ldp-color-picker-more" type="button" aria-expanded="false">更多颜色</button> </div> <div class="ldp-color-picker-advanced" hidden> <label class="ldp-color-picker-slider"><span>色相</span><input class="ldp-color-picker-hue" type="range" min="0" max="359" step="1"><output class="ldp-color-picker-hue-value"></output></label> <label class="ldp-color-picker-slider"><span>饱和度</span><input class="ldp-color-picker-saturation" type="range" min="0" max="100" step="1"><output class="ldp-color-picker-saturation-value"></output></label> <label class="ldp-color-picker-slider"><span>明度</span><input class="ldp-color-picker-brightness" type="range" min="0" max="100" step="1"><output class="ldp-color-picker-brightness-value"></output></label> </div>`; overlay.append(colorPickerPopover); const colorPickerTitle = colorPickerPopover.querySelector('.ldp-color-picker-title'); const colorPickerHexInput = colorPickerPopover.querySelector('.ldp-color-picker-hex'); const colorPickerPresetButtons = colorPickerPopover.querySelectorAll('.ldp-color-picker-preset'); const colorPickerMoreButton = colorPickerPopover.querySelector('.ldp-color-picker-more'); const colorPickerAdvanced = colorPickerPopover.querySelector('.ldp-color-picker-advanced'); const colorPickerHueInput = colorPickerPopover.querySelector('.ldp-color-picker-hue'); const colorPickerSaturationInput = colorPickerPopover.querySelector('.ldp-color-picker-saturation'); const colorPickerBrightnessInput = colorPickerPopover.querySelector('.ldp-color-picker-brightness'); const colorPickerHueValue = colorPickerPopover.querySelector('.ldp-color-picker-hue-value'); const colorPickerSaturationValue = colorPickerPopover.querySelector('.ldp-color-picker-saturation-value'); const colorPickerBrightnessValue = colorPickerPopover.querySelector('.ldp-color-picker-brightness-value'); let activeColorInput = null; let colorPickerHsv = { h: 0, s: 0, v: 100 }; let colorPickerCommitFrame = 0; let pendingColorPickerCommit = null; const normalizeColorPickerHex = (rawValue) => { const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(rawValue || '').trim()); if (!match) return ''; const digits = match[1].length === 3 ? Array.from(match[1], (digit) => `${digit}${digit}`).join('') : match[1]; return `#${digits.toUpperCase()}`; }; const colorPickerHexToHsv = (hex) => { const value = normalizeColorPickerHex(hex).slice(1); const red = parseInt(value.slice(0, 2), 16) / 255; const green = parseInt(value.slice(2, 4), 16) / 255; const blue = parseInt(value.slice(4, 6), 16) / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const delta = max - min; let hue = 0; if (delta) { if (max === red) hue = 60 * (((green - blue) / delta) % 6); else if (max === green) hue = 60 * ((blue - red) / delta + 2); else hue = 60 * ((red - green) / delta + 4); } if (hue < 0) hue += 360; return { h: Math.round(hue) % 360, s: max ? Math.round((delta / max) * 100) : 0, v: Math.round(max * 100) }; }; const colorPickerHsvToHex = ({ h, s, v }) => { const saturation = s / 100; const brightness = v / 100; const chroma = brightness * saturation; const sector = h / 60; const intermediate = chroma * (1 - Math.abs((sector % 2) - 1)); const offset = brightness - chroma; const channels = sector < 1 ? [chroma, intermediate, 0] : sector < 2 ? [intermediate, chroma, 0] : sector < 3 ? [0, chroma, intermediate] : sector < 4 ? [0, intermediate, chroma] : sector < 5 ? [intermediate, 0, chroma] : [chroma, 0, intermediate]; return `#${channels.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`; }; const syncColorPickerAdvanced = () => { const { h, s, v } = colorPickerHsv; colorPickerHueInput.value = String(h); colorPickerSaturationInput.value = String(s); colorPickerBrightnessInput.value = String(v); colorPickerHueValue.textContent = `${h}°`; colorPickerSaturationValue.textContent = `${s}%`; colorPickerBrightnessValue.textContent = `${v}%`; colorPickerHueInput.style.setProperty('--ldp-color-slider', 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)'); colorPickerHueInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s: 100, v: 100 })); colorPickerSaturationInput.style.setProperty('--ldp-color-slider', `linear-gradient(to right,${colorPickerHsvToHex({ h, s: 0, v })},${colorPickerHsvToHex({ h, s: 100, v })})`); colorPickerSaturationInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v })); colorPickerBrightnessInput.style.setProperty('--ldp-color-slider', `linear-gradient(to right,#000000,${colorPickerHsvToHex({ h, s, v: 100 })})`); colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v })); }; const syncColorPickerPopover = () => { if (!activeColorInput) return; const color = activeColorInput.value.toUpperCase(); colorPickerTitle.textContent = activeColorInput.getAttribute('aria-label') || '选择颜色'; colorPickerHexInput.value = color; colorPickerHsv = colorPickerHexToHsv(color); syncColorPickerAdvanced(); colorPickerHexInput.setAttribute('aria-invalid', 'false'); colorPickerPresetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.color === color))); }; const commitScheduledColorPickerValue = () => { colorPickerCommitFrame = 0; const pending = pendingColorPickerCommit; pendingColorPickerCommit = null; if (!pending || !pending.input.isConnected) return; pending.input.value = pending.value; pending.input.dispatchEvent(new window.Event('input', { bubbles: true })); }; const scheduleColorPickerValue = (value) => { if (!activeColorInput) return; pendingColorPickerCommit = { input: activeColorInput, value }; if (!colorPickerCommitFrame) colorPickerCommitFrame = window.requestAnimationFrame(commitScheduledColorPickerValue); }; const flushScheduledColorPickerValue = () => { if (!pendingColorPickerCommit) return; if (colorPickerCommitFrame) window.cancelAnimationFrame(colorPickerCommitFrame); commitScheduledColorPickerValue(); }; const closeColorPickerPopover = ({ restoreFocus = false } = {}) => { if (colorPickerPopover.hidden) return; flushScheduledColorPickerValue(); const previousInput = activeColorInput; colorPickerPopover.hidden = true; activeColorInput = null; if (restoreFocus && previousInput) previousInput.focus(); }; const positionColorPickerPopover = (input) => { const margin = 12; const gap = 8; const inputRect = input.getBoundingClientRect(); const popoverRect = colorPickerPopover.getBoundingClientRect(); const left = Math.min(Math.max(margin, inputRect.left), Math.max(margin, window.innerWidth - popoverRect.width - margin)); const below = inputRect.bottom + gap; const top = below + popoverRect.height <= window.innerHeight - margin ? below : Math.max(margin, inputRect.top - popoverRect.height - gap); colorPickerPopover.style.left = `${Math.round(left)}px`; colorPickerPopover.style.top = `${Math.round(top)}px`; }; const openColorPickerPopover = (input) => { if (!input || input.disabled) return; hideSettingHelp(); closeFontFamilyMenu(); stopSettingsRangeDrag(); stopSettingsColorPick(); activeColorInput = input; syncColorPickerPopover(); colorPickerPopover.hidden = false; positionColorPickerPopover(input); colorPickerHexInput.focus(); colorPickerHexInput.select(); }; const applyColorPickerValue = (value, closeAfter = false) => { if (!activeColorInput) return; flushScheduledColorPickerValue(); activeColorInput.value = value; activeColorInput.dispatchEvent(new window.Event('input', { bubbles: true })); syncColorPickerPopover(); if (closeAfter) closeColorPickerPopover({ restoreFocus: true }); }; settingsColorInputs.forEach((input) => { input.setAttribute('aria-haspopup', 'dialog'); input.addEventListener('click', (event) => { event.preventDefault(); openColorPickerPopover(input); }); input.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openColorPickerPopover(input); }); }); colorPickerPresetButtons.forEach((button) => button.addEventListener('click', () => { applyColorPickerValue(button.dataset.color, true); })); colorPickerHexInput.addEventListener('input', () => { const rawValue = colorPickerHexInput.value.trim(); const color = /^#?[\da-f]{6}$/i.test(rawValue) ? normalizeColorPickerHex(rawValue) : ''; colorPickerHexInput.setAttribute('aria-invalid', String(rawValue.length >= 7 && !color)); if (color) applyColorPickerValue(color); }); colorPickerHexInput.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); closeColorPickerPopover({ restoreFocus: true }); return; } if (event.key !== 'Enter') return; const color = normalizeColorPickerHex(colorPickerHexInput.value); if (!color) { colorPickerHexInput.setAttribute('aria-invalid', 'true'); return; } event.preventDefault(); applyColorPickerValue(color, true); }); colorPickerHexInput.addEventListener('blur', () => { if (!activeColorInput || colorPickerPopover.contains(document.activeElement)) return; colorPickerHexInput.value = activeColorInput.value.toUpperCase(); colorPickerHexInput.setAttribute('aria-invalid', 'false'); }); colorPickerMoreButton.addEventListener('click', () => { const expanded = colorPickerAdvanced.hidden; colorPickerAdvanced.hidden = !expanded; colorPickerMoreButton.setAttribute('aria-expanded', String(expanded)); colorPickerMoreButton.textContent = expanded ? '收起调色' : '更多颜色'; if (activeColorInput) positionColorPickerPopover(activeColorInput); }); [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput].forEach((input) => input.addEventListener('input', () => { colorPickerHsv = { h: Number(colorPickerHueInput.value), s: Number(colorPickerSaturationInput.value), v: Number(colorPickerBrightnessInput.value), }; const color = colorPickerHsvToHex(colorPickerHsv); colorPickerHexInput.value = color; colorPickerHexInput.setAttribute('aria-invalid', 'false'); syncColorPickerAdvanced(); colorPickerPresetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.color === color))); scheduleColorPickerValue(color); })); [colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput] .forEach((input) => input.addEventListener('change', flushScheduledColorPickerValue)); colorPickerPopover.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; event.preventDefault(); closeColorPickerPopover({ restoreFocus: true }); }); colorPickerPopover.addEventListener('pointerdown', (event) => event.stopPropagation()); colorPickerPopover.addEventListener('click', (event) => event.stopPropagation()); overlay.addEventListener('pointerdown', (event) => { if (colorPickerPopover.hidden || colorPickerPopover.contains(event.target) || event.target === activeColorInput) return; closeColorPickerPopover(); }, true); const setSettingHelp = (target, copy) => { if (target && copy) target.dataset.settingHelp = copy; }; const setSettingRowHelp = (control, copy) => { setSettingHelp(control && control.closest('.ldp-setting-row'), copy); }; imageProfileTabs.forEach((tab) => setSettingHelp(tab, `选择要查看或调整的${tab.textContent.trim()}图片比例;已共享的项目在三种形态中保持一致。`)); setSettingRowHelp(imageScaleRange, '控制当前所选形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。'); setSettingRowHelp(lightboxOriginalByDefaultInput, '开启后,灯箱首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已缓存原图时仍优先展示,否则沿用预览图。修改后立即保存。'); setSettingRowHelp(lightboxCommentsExpandedInput, '控制图片灯箱首次打开时是否展开评论面板;灯箱内仍可随时手动收起或展开。修改后立即保存。'); setSettingRowHelp(lightboxDescriptionExpandedInput, '控制图片描述的默认收纳状态;灯箱内手动收纳或拖动描述高度后也会自动记住。修改后立即保存。'); fontProfileTabs.forEach((tab) => setSettingHelp(tab, `选择要查看或调整的${tab.textContent.trim()}显示场景;未共享项目只保存到这个场景,已共享项目会同步用于三种形态。`)); layoutProfileTabs.forEach((tab) => setSettingHelp(tab, `选择要查看或调整的${tab.textContent.trim()}布局;未共享区域只保存到这个场景,已共享区域会同步用于三种形态。`)); appearanceProfileTabs.forEach((tab) => setSettingHelp(tab, `选择要查看或调整的${tab.textContent.trim()}外观;未共享项目按形态保存,已共享项目在三种形态中保持一致。`)); profileSharingInputs.forEach((input) => setSettingHelp(input, input.dataset.profileSharingCategory === 'image' ? '开启后只让这一项由浮窗、全屏和移动共享,并以当前选中形态的值为起点;图片设置修改后立即保存。' : '可先修改数值再选择共享,也可先选择共享再修改;点击当前分类的“确认应用”后,一次保存数值和共享状态。')); const layoutHelp = { left: '控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。', main: '控制主帖和楼层正文实际占用的宽度。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。', gap: '控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。', timeline: '控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。', right: '控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。', }; layoutRatioInputs.forEach((input) => setSettingRowHelp(input, layoutHelp[input.dataset.layoutRegion])); setSettingHelp(readerWindowWidthInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。'); setSettingHelp(readerWindowHeightInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。'); setSettingHelp(readerWindowXInput.closest('.ldp-reader-window-field'), '设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。'); setSettingHelp(readerWindowYInput.closest('.ldp-reader-window-field'), '设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。'); setSettingHelp(readerWindowPinInput.closest('.ldp-reader-window-option'), '开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。'); setSettingHelp(readerWindowLockInput.closest('.ldp-reader-window-option'), '开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。'); appearanceSettingElements.forEach(({ input, help }) => setSettingRowHelp(input, help)); const presetHelp = { low: '同时收紧请求来源、DOM 窗口和共享调度,优先降低网络、内存与主线程占用;下次打开阅读器生效。', balanced: '在请求需求、滚动跟手、DOM 占用和鲁棒调度之间取默认平衡;下次打开阅读器生效。', high: '扩大请求批量、DOM 窗口和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。', custom: '表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。', }; performancePresetButtons.forEach((button) => setSettingHelp(button, presetHelp[button.dataset.performancePreset])); performanceInputs.forEach((input) => setSettingRowHelp(input, PERFORMANCE_SETTING_FIELDS.find((field) => field.key === input.dataset.performanceKey).help)); JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => setSettingRowHelp(jumpHighlightControls[field.key].input, field.help)); setSettingRowHelp(historyButtonsAlwaysVisibleInput, '开启后,浏览历史中可用的左右导航按钮会一直显示,并禁用“左右按钮触发区域”滑块;关闭后,按钮仅在鼠标进入对应边缘区域或键盘聚焦时显示。修改后立即保存。'); setSettingRowHelp(historyEdgeTriggerRange, `设置阅读器左右两侧各自用于唤出历史导航按钮的宽度,占当前阅读器宽度的 ${HISTORY_EDGE_TRIGGER_MIN}%–${HISTORY_EDGE_TRIGGER_MAX}%;区域透明且不会遮挡正文操作,0% 表示关闭鼠标触发。修改后立即保存。`); setSettingRowHelp(historySortModeSelect, '默认的“最近点击”使用每次打开的最新时间倒序排列,距离现在越近越靠前,因此重开旧帖后它会回到列表顶部;“首次点击”会固定每条记录第一次进入历史时的时间,重开不会改变位置。现有记录无法还原更早的首次点击,会以当前保存时间作为起点。修改后立即保存。'); setSettingRowHelp(openTopicsAtFirstPostInput, '开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。'); setSettingRowHelp(expandNestedRepliesByDefaultInput, '开启后,父楼层下面默认铺开直属二级回复;可与对应正式楼层同时展开,但至少要保留一种显示方式。修改后立即保存并应用到当前帖子。'); setSettingRowHelp(expandLeafNestedRepliesInput, '开启后,二级回复在主信息流中的对应楼层默认完整展开;可与父楼层下的直属回复同时展开,但至少要保留一种显示方式。修改后立即保存并应用到当前帖子。'); setSettingRowHelp(boostCopyModeSelect, '选择复制 Boost 时尾巴如何生成:“数字递增”会在每次复制后加步长,“固定文字”每次都追加同一段文字。修改后立即保存。'); setSettingRowHelp(boostCopyPrefixInput, '填写复制结果最前面的文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字,修改后立即保存。'); setSettingRowHelp(boostCopyCounterMarkerInput, '数字递增模式下,填写数字前面的符号或文字,例如“+”会得到“原 Boost +1”。留空时数字会直接接在原文后面。'); setSettingRowHelp(boostCopyCounterStepInput, '数字递增模式下,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。'); setSettingRowHelp(boostCopyFixedSuffixInput, '固定文字模式下,每次复制都会把这里的文字追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。'); setSettingRowHelp(boostCopyPreview, '这里展示当前规则实际会复制出的结果。数字递增模式会同时展示连续两次复制,方便确认步长是否正确。'); cacheSelects.forEach((input, index) => setSettingHelp(input.closest('.ldp-cache-row'), CACHE_TYPES[index].help)); setSettingHelp(cacheClearBtn, '只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。'); const readerWindow = readerShell.readerWindow || createReaderWindowController(overlay, modal); readerShell.readerWorkspace = readerWorkspace; readerShell.readerWindow = readerWindow; readerShell.topicId = Number(topicId); readerShell.targetPostNumber = targetPostNumber; readerShell.topicNavMetadata = sourceTopicNavMetadata; readerShell.commentsHeader = commentsHeader; overlay._ldpReaderShell = readerShell; const syncReaderPlacementUi = () => { const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES; const visibleModeCount = allowedModes.length; readerPlacementControl.hidden = visibleModeCount < 2; readerPlacementStrip.hidden = visibleModeCount < 2; readerCapsule.classList.toggle('ldp-reader-placement-available', visibleModeCount > 1); if (visibleModeCount < 2) readerCapsule.classList.remove('ldp-reader-placement-expanded'); const activeMode = directTopicRoute ? currentTopicReaderMode() : readerWorkspace.mode(); readerPlacementOptions.forEach((option) => { const optionMode = option.dataset.readerPlacement; option.hidden = !allowedModes.includes(optionMode); option.classList.toggle('active', option.dataset.readerPlacement === activeMode); }); }; const applyReaderPlacement = (nextMode) => { const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES; if (!allowedModes.includes(nextMode)) return; if (directTopicRoute) { setPref('topicReaderMode', 'floating'); syncReaderPlacementUi(); return; } readerWorkspace.setMode(nextMode); readerWindow.syncMode(); syncLayoutBtn(); syncReaderBackgroundMode(); applyLayoutRatios(); if (nextMode !== readerWorkspace.mode()) showSelectionToast('页面空间不足,暂时保持浮窗'); syncReaderPlacementUi(); }; if (!reusingShell) { readerPlacementControl.addEventListener('pointerenter', () => { readerCapsule.classList.add('ldp-reader-placement-expanded'); }); readerCapsule.addEventListener('pointerleave', () => { readerCapsule.classList.remove('ldp-reader-placement-expanded'); }); readerPlacementOptions.forEach((option) => { option.addEventListener('click', () => applyReaderPlacement(option.dataset.readerPlacement)); }); } syncReaderPlacementUi(); let latestTopicShellData = cachedTopicPreview; let topicNavMetadataObserver = null; let topicNavMetadataSyncFrame = 0; let topicNavMetadataStopTimer = 0; let topicNavMetadataOverride = null; let topicEditorState = null; const stopTopicNavMetadataHydration = () => { if (topicNavMetadataObserver) topicNavMetadataObserver.disconnect(); if (topicNavMetadataSyncFrame) cancelAnimationFrame(topicNavMetadataSyncFrame); if (topicNavMetadataStopTimer) clearTimeout(topicNavMetadataStopTimer); topicNavMetadataObserver = null; topicNavMetadataSyncFrame = 0; topicNavMetadataStopTimer = 0; }; const renderTopicShell = (topicData) => { if (!topicData) return; latestTopicShellData = topicData; const liveTopicNavMetadata = readTopicNavMetadata(null, topicId); sourceTopicNavMetadata = mergeTopicNavMetadata( topicNavMetadataOverride || liveTopicNavMetadata, mergeTopicNavMetadata(sourceTopicNavMetadata, cachedTopicNavMetadata(topicData)), ); readerShell.topicNavMetadata = sourceTopicNavMetadata; const topicMetadataFilled = fillTopicNavMetadata(topicData, sourceTopicNavMetadata); const topicMetadataRemembered = rememberTopicNavMetadata(topicId, topicData, sourceTopicNavMetadata); if (topicMetadataFilled && !topicMetadataRemembered) setCachedTopicData(topicId, topicData); const cachedPosts = Array.isArray(topicData.post_stream && topicData.post_stream.posts) ? topicData.post_stream.posts : []; const owner = topicData._opUsername || (topicData.details && topicData.details.created_by && topicData.details.created_by.username) || (cachedPosts.find((post) => +post.post_number === 1) || {}).username || ''; const title = String(topicData.title || '').trim(); if (title) { overlay.querySelector('.ldp-title-jump').textContent = title; syncReaderDocumentTitle(title); } const topicEditTrigger = overlay.querySelector('.ldp-topic-edit-trigger'); const canEditTopic = topicData.details && topicData.details.can_edit === true; topicEditTrigger.hidden = !canEditTopic; if (!canEditTopic && topicEditorState) topicEditorState.close(true); const ownerHtml = owner ? `<a class="ldp-user-link ldp-topic-owner-link" href="${userProfileUrl(owner)}" target="_blank" rel="noopener" data-user-card="${escAttr(owner)}">@${esc(owner)}</a>` : '@?'; const metaParts = [ `${esc(String(topicData.posts_count || 0))} 帖`, `${esc(String(topicData.views || 0))} 浏览`, ]; if (topicData.like_count != null) { metaParts.push(`${esc(String(Math.max(0, +topicData.like_count || 0)))} 赞`); } if (topicData.participant_count != null) { metaParts.push(`${esc(String(Math.max(0, +topicData.participant_count || 0)))} 用户`); } overlay.querySelector('.ldp-meta-stats').innerHTML = metaParts.join(' · '); overlay.querySelector('.ldp-meta-owner-value').innerHTML = ownerHtml; overlay.querySelector('.ldp-meta-owner').hidden = false; const tagsEl = overlay.querySelector('.ldp-topic-tags'); tagsEl.innerHTML = renderTopicNavLinks(topicData, sourceTopicNavMetadata); tagsEl.hidden = !tagsEl.innerHTML; syncReaderSvgSymbols(tagsEl); const voteSlot = overlay.querySelector('.ldp-topic-vote-slot'); const hasTopicVote = topicData.can_vote === true || topicData.user_voted === true || +topicData.vote_count > 0; voteSlot.hidden = !hasTopicVote; voteSlot.innerHTML = hasTopicVote ? `<button class="ldp-topic-vote${topicData.user_voted ? ' on' : ''}" type="button" data-topic-vote aria-pressed="${topicData.user_voted ? 'true' : 'false'}"${topicData.can_vote === false && !topicData.user_voted ? ' disabled' : ''}>▲ <span>${Math.max(0, +topicData.vote_count || 0)}</span> 票</button>` : ''; }; const syncTopicNavMetadataFromHost = () => { if (topicNavMetadataOverride) return false; if (!overlay.isConnected) { stopTopicNavMetadataHydration(); return false; } const liveMetadata = readTopicNavMetadata(null, topicId); if (!topicNavMetadataHasIcons(liveMetadata)) return false; stopTopicNavMetadataHydration(); sourceTopicNavMetadata = mergeTopicNavMetadata(liveMetadata, sourceTopicNavMetadata); readerShell.topicNavMetadata = sourceTopicNavMetadata; renderTopicShell(latestTopicShellData); return true; }; const queueTopicNavMetadataSync = () => { if (topicNavMetadataSyncFrame) return; topicNavMetadataSyncFrame = requestAnimationFrame(() => { topicNavMetadataSyncFrame = 0; syncTopicNavMetadataFromHost(); }); }; const startTopicNavMetadataHydration = () => { if (!directTopicRoute || topicNavMetadataHasIcons(sourceTopicNavMetadata) || !document.body) return; topicNavMetadataObserver = new MutationObserver(queueTopicNavMetadataSync); topicNavMetadataObserver.observe(document.body, { childList: true, subtree: true }); topicNavMetadataStopTimer = setTimeout(stopTopicNavMetadataHydration, 5000); queueTopicNavMetadataSync(); }; renderTopicShell(cachedTopicPreview); startTopicNavMetadataHydration(); const postRequestScheduler = createReaderRequestScheduler(performance); ACTIVE_READER_REQUEST_SCHEDULER = postRequestScheduler; let rateLimitNoticeTimer = 0; let rateLimitChallengePending = false; const rateLimitCooldownRemaining = () => { try { const state = postRequestScheduler.snapshot(); return Math.max(0, Number(state && state.cooldownRemaining) || 0); } catch (error) {} return 0; }; const syncRateLimitNotice = () => { if (!rateLimitNotice || !rateLimitDetail) return; const remaining = rateLimitCooldownRemaining(); if (remaining <= 0) { rateLimitNotice.hidden = true; delete rateLimitNotice.dataset.cooldownSeconds; if (rateLimitChallengePending && rateLimitChallenge) { rateLimitChallengePending = false; openCloudflareChallengePopup(rateLimitChallenge.href); } return; } rateLimitChallengePending = true; const seconds = Math.max(1, Math.ceil(remaining / 1000)); if (rateLimitNotice.dataset.cooldownSeconds !== String(seconds)) { rateLimitNotice.dataset.cooldownSeconds = String(seconds); rateLimitDetail.textContent = `触发 429,正在等待 ${seconds} 秒;倒计时结束后将自动打开过盾浮窗。`; } rateLimitNotice.hidden = false; }; const stopRateLimitNotice = () => { if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer); rateLimitNoticeTimer = 0; if (!rateLimitNotice) return; rateLimitNotice.hidden = true; delete rateLimitNotice.dataset.cooldownSeconds; }; syncRateLimitNotice(); rateLimitNoticeTimer = setInterval(syncRateLimitNotice, 500); const loader = createLoader(topicId, postRequestScheduler, performance); const initialTopicRequest = loader.init(); const notificationState = { group: 'all', page: 0, token: 0, total: 0, hasNext: false, query: '' }; let notificationQueuedRequestKey = ''; const currentReaderRequestScheduler = () => ( readerShell.activeContext && readerShell.activeContext.postRequestScheduler ) || ACTIVE_READER_REQUEST_SCHEDULER || postRequestScheduler; const historyState = { page: 0, query: '', multi: false, scope: 'page' }; const historySelection = new Set(); const bookmarksState = { page: 0, query: '', token: 0, hasNext: false, entries: [], allEntries: [], reactionEntries: [], reactionLoadedAt: 0, reactionUsername: '', reactionFilter: '', type: PREFS.bookmarkTabOrder[0], multi: false, scope: 'page', allIds: null, }; const givenReactionOverrides = readerShell.givenReactionOverrides instanceof Map ? readerShell.givenReactionOverrides : new Map(); readerShell.givenReactionOverrides = givenReactionOverrides; const bookmarksSelection = new Set(); const clearBookmarksSelectionScope = () => { bookmarksState.allIds = null; bookmarksSelection.clear(); }; const cachedNotificationWarmup = getCachedNotificationPage(notificationPageCacheKey('all', 0)); if (cachedNotificationWarmup) { notificationState.total = cachedNotificationWarmup.total; notificationState.hasNext = cachedNotificationWarmup.hasNext; } syncNotificationUnreadUi(ME_CURRENT_USER ? NOTIFICATION_UNREAD_COUNT : cachedUnreadNotificationCount()); const positionHeaderPopover = (button, popover) => { if (!button || !popover || popover.hidden) return; const buttonRect = button.getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); const gap = 8; const left = Math.max(12, Math.min(window.innerWidth - popoverRect.width - 12, buttonRect.right - popoverRect.width)); const below = buttonRect.bottom + gap; const above = buttonRect.top - popoverRect.height - gap; popover.style.left = `${Math.round(left)}px`; popover.style.top = `${Math.round( below + popoverRect.height <= window.innerHeight - 12 || above < 12 ? below : above )}px`; }; const positionNotificationsPopover = () => positionHeaderPopover(notificationsBtn, notificationsPopover); const closeNotifications = () => { notificationsPopover.hidden = true; notificationsBtn.setAttribute('aria-expanded', 'false'); }; const positionHistoryPopover = () => positionHeaderPopover(historyPanel.toggle, historyPanel.popover); const closeHistory = () => { historyPanel.popover.hidden = true; historyPanel.toggle.setAttribute('aria-expanded', 'false'); historyState.multi = false; historySelection.clear(); }; const positionBookmarksPopover = () => positionHeaderPopover(bookmarksPanel.toggle, bookmarksPanel.popover); const closeBookmarks = () => { readerShell.cancelBookmarksSearch?.(); bookmarksPanel.popover.hidden = true; bookmarksPanel.toggle.setAttribute('aria-expanded', 'false'); bookmarksState.token++; bookmarksState.multi = false; clearBookmarksSelectionScope(); }; const positionHeaderPopovers = () => { positionNotificationsPopover(); positionHistoryPopover(); positionBookmarksPopover(); }; const syncHistorySortToggle = () => { const recentFirst = PREFS.historySortMode === 'recent-viewed'; const label = recentFirst ? '最近打开优先;点击切换为首次打开顺序' : '首次打开顺序固定;点击切换为最近打开优先'; historyPanel.sortToggle.innerHTML = icon(recentFirst ? 'history' : 'pin'); historyPanel.sortToggle.setAttribute('aria-label', label); historyPanel.sortToggle.setAttribute('aria-pressed', String(!recentFirst)); historyPanel.sortToggle.dataset.ldpTooltipLabel = label; }; const syncCollectionSelectionControls = ( panel, state, selection, scopeIds, itemLabel, multiDisabled, selectDisabled, unavailable = false ) => { const fullySelected = Array.isArray(scopeIds) && scopeIds.length > 0 && scopeIds.every((id) => selection.has(id)); panel.defaultActions.hidden = unavailable || state.multi; panel.bulkActions.hidden = unavailable || !state.multi; panel.multi.disabled = multiDisabled; panel.selectScope.value = state.scope; panel.selectToggle.disabled = selectDisabled; panel.selectToggle.innerHTML = icon(fullySelected ? 'squareCheck' : 'square'); panel.selectToggle.setAttribute('aria-pressed', String(fullySelected)); panel.selectToggle.setAttribute('aria-label', `${fullySelected ? '全不选' : '全选'}${state.scope === 'all' ? '全部页面' : '本页'}${itemLabel}`); panel.deleteSelected.disabled = selection.size === 0; panel.deleteSelectedLabel.textContent = String(selection.size); panel.deleteSelectedLabel.hidden = selection.size === 0; }; function renderHistoryPage() { syncHistorySortToggle(); const allEntries = orderedTopicHistory(); const availableTopicIds = new Set(allEntries.map((entry) => Number(entry.topicId))); historySelection.forEach((topicId) => { if (!availableTopicIds.has(topicId)) historySelection.delete(topicId); }); const entries = historyState.query ? allEntries.filter((entry) => historyMatchesSearch(entry, historyState.query)) : allEntries; const totalPages = Math.max(1, Math.ceil(entries.length / HISTORY_PAGE_SIZE)); historyState.page = Math.max(0, Math.min(totalPages - 1, historyState.page)); const start = historyState.page * HISTORY_PAGE_SIZE; const pageEntries = entries.slice(start, start + HISTORY_PAGE_SIZE); releasePersistentAvatarImages(historyPanel.list); historyPanel.list.innerHTML = pageEntries.length ? pageEntries.map((entry) => { const cachedSource = entry.avatarTemplate && touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(entry.avatarTemplate, 64)); const historyAvatar = cachedSource ? `<img class="ldp-notification-avatar" src="${escAttr(cachedSource)}" alt="" loading="lazy" decoding="async">` : entry.avatarTemplate ? `<span class="ldp-notification-avatar ldp-notification-avatar-fallback" data-ldp-avatar-template="${escAttr(entry.avatarTemplate)}">${esc(String(entry.ownerUsername || '?').slice(0, 1).toUpperCase())}</span>` : `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${icon('history')}</span>`; const historyUserAvatar = userCardAvatarHtml(historyAvatar, entry.ownerUsername); const selected = historySelection.has(Number(entry.topicId)); const targetPostNumber = Math.max(1, Number(entry.postNumber) || 1); return `<div class="ldp-history-item ldp-collection-item${historyState.multi ? ' multi' : ''}${selected ? ' selected' : ''}" data-history-topic-id="${entry.topicId}" data-history-post-number="${targetPostNumber}" data-history-actor="${escAttr(entry.ownerUsername || '')}"> ${historyState.multi ? `<label class="ldp-history-select ldp-collection-select" data-ldp-tooltip-label="选择这条浏览历史"> <input class="ldp-history-select-input ldp-collection-select-input" type="checkbox" aria-label="选择《${escAttr(entry.title)}》"${selected ? ' checked' : ''}> </label>` : ''} <a class="ldp-notification-item ldp-history-link" data-ldp-preserve-target-post="1" href="${BASE}/t/${entry.topicId}/${targetPostNumber}?${NATIVE_BYPASS_PARAM}=1"> ${historyUserAvatar} <span class="ldp-notification-copy"> <span class="ldp-notification-title">${esc(entry.title)}</span> <span class="ldp-notification-meta">${esc(`${entry.postsCount || 0} 帖 · ${fmtTime(historyEntrySortTime(entry))}`)}</span> </span> </a> ${historyState.multi ? '' : `<button class="ldp-history-delete ldp-collection-delete" type="button" aria-label="删除这条浏览历史">${icon('trash')}</button>`} </div>`; }).join('') : `<div class="ldp-notification-empty">${historyState.query ? '没有匹配的浏览历史' : '暂无浏览历史'}</div>`; hydrateHistoryAvatars(historyPanel.list, pageEntries); historyPanel.pagePrev.disabled = historyState.page <= 0; historyPanel.pageNext.disabled = historyState.page >= totalPages - 1; const scopeEntries = historyState.scope === 'all' ? entries : pageEntries; syncCollectionSelectionControls( historyPanel, historyState, historySelection, scopeEntries.map((entry) => Number(entry.topicId)), '浏览历史', allEntries.length === 0, scopeEntries.length === 0 ); historyPanel.clear.disabled = allEntries.length === 0; historyPanel.pageInfo.textContent = entries.length ? `${historyState.page + 1} / ${totalPages}` : '暂无记录'; positionHistoryPopover(); } readerShell.refreshHistoryPanel = () => { if (historyPanel.popover.hidden) return; if (PREFS.historySortMode === 'recent-viewed') historyState.page = 0; renderHistoryPage(); }; const bookmarkPageUrl = async (page, query) => { const username = await ensureMe(); if (!username) throw new Error('登录后才能查看收藏'); const params = new URLSearchParams({ page: String(Math.max(0, Number(page) || 0)) }); if (query) params.set('q', query); return `${BASE}/u/${encodeURIComponent(username)}/bookmarks.json?${params}`; }; const fetchBookmarkPage = async (page, query) => fetchJSON(await bookmarkPageUrl(page, query), { key: `bookmarks:${page}:${query}`, priority: POST_REQUEST_PRIORITY.auxiliary, cacheMode: 'default', }); const bookmarkPagePayload = (result) => result && result.user_bookmark_list || result || {}; const bookmarkAvatarHtml = (entry) => { const user = entry && entry.user || {}; const avatar = persistentAvatarHtml( user.avatar_template, 64, 'ldp-notification-avatar', user.name || user.username || '?' ); return avatar ? userCardAvatarHtml(avatar, user.username) : `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${icon('bookmark')}</span>`; }; const bookmarkTarget = (entry) => ({ topicId: Number(entry && entry.topic_id) || 0, postNumber: Math.max(1, Number(entry && entry.linked_post_number) || 1), }); const reactionPageEntries = (result) => { if (Array.isArray(result)) return result; if (Array.isArray(result && result.user_reactions)) return result.user_reactions; if (Array.isArray(result && result.reaction_users)) return result.reaction_users; if (Array.isArray(result && result.reactions)) return result.reactions; return []; }; const givenReactionRecord = (post, topic, reaction, id = 0, reactedAt = '', title = '') => { const postId = Number(post && post.id) || 0; const topicId = Number(post && post.topic_id || topic && topic.id) || 0; return { id: Number(id) || postId, topicId, postId, postNumber: Math.max(1, Number(post && post.post_number) || 1), reaction, title: String(post && post.topic_title || title || topic && topic.title || `帖子 #${topicId}`), authorUsername: String(post && post.username || ''), avatarTemplate: String(post && post.avatar_template || ''), reactedAt: String(reactedAt || ''), }; }; const normalizeGivenReaction = (entry) => { const post = entry && entry.post || {}; const topic = post.topic || {}; const user = post.user || {}; const reactionData = entry && entry.reaction || {}; const id = Number(entry && entry.id) || 0; const reaction = String(reactionData.reaction_value || entry && entry.reaction_value || ''); const sourcePost = { id: entry && entry.post_id || post.id, topic_id: post.topic_id || topic.id || entry && entry.topic_id, post_number: post.post_number || entry && entry.post_number, topic_title: post.topic_title || topic.title || entry && entry.topic_title, username: post.username || user.username, avatar_template: post.avatar_template || user.avatar_template, }; if (!id || !Number(sourcePost.topic_id) || !Number(sourcePost.id) || !reaction) return null; return givenReactionRecord(sourcePost, null, reaction, id, entry && entry.created_at || reactionData.created_at); }; const normalizeGivenLike = (entry) => { const topicId = Number(entry && entry.topic_id) || 0; const postId = Number(entry && entry.post_id) || 0; if (Number(entry && entry.action_type) !== 1 || !topicId || !postId) return null; return givenReactionRecord({ ...entry, id: postId }, null, 'heart', entry.id, entry.created_at, entry.title); }; const mergeGivenReactionOverrides = (entries) => { const entriesByPostId = new Map((Array.isArray(entries) ? entries : []) .map((entry) => [Number(entry && entry.postId), entry]) .filter(([postId]) => postId > 0)); givenReactionOverrides.forEach((override, postId) => { const serverEntry = entriesByPostId.get(postId); if (override && Number(serverEntry && serverEntry.id) > 0 && serverEntry.reaction === override.reaction) { givenReactionOverrides.delete(postId); return; } if (!override && !serverEntry) { givenReactionOverrides.delete(postId); return; } entriesByPostId.delete(postId); if (override) entriesByPostId.set(postId, override); }); return Array.from(entriesByPostId.values()).sort(compareGivenReactionRecords); }; const confirmedGivenReactionRecord = (post, topic, reaction) => { const postId = Number(post && post.id) || 0; return givenReactionRecord(post, topic, reaction, -postId, new Date().toISOString()); }; const rememberConfirmedGivenReaction = (post, topic) => { const postId = Number(post && post.id) || 0; if (!postId) return false; const current = post && post.current_user_reaction; const reaction = String(current && current.id || ''); givenReactionOverrides.set(postId, reaction ? confirmedGivenReactionRecord(post, topic, reaction) : null); return true; }; const applyConfirmedGivenLike = (entries, post, topic, acted) => { const postId = Number(post && post.id) || 0; if (!postId) return Array.isArray(entries) ? entries : []; const entriesByPostId = new Map((Array.isArray(entries) ? entries : []) .map((entry) => [Number(entry && entry.postId), entry]) .filter(([entryPostId]) => entryPostId > 0)); const current = entriesByPostId.get(postId); if (acted && (!current || current.reaction === 'heart')) { entriesByPostId.set(postId, confirmedGivenReactionRecord(post, topic, 'heart')); } else if (!acted && current && current.reaction === 'heart') { entriesByPostId.delete(postId); } return Array.from(entriesByPostId.values()).sort(compareGivenReactionRecords); }; const fetchGivenReactionPage = (username, beforeReactionUserId = 0) => { const params = new URLSearchParams({ username }); if (beforeReactionUserId > 0) params.set('before_reaction_user_id', String(beforeReactionUserId)); return fetchJSON(`${BASE}/discourse-reactions/posts/reactions.json?${params}`, { key: `reactions-given:${username}:${beforeReactionUserId}`, priority: POST_REQUEST_PRIORITY.auxiliary, cacheMode: 'default', }); }; const collectPagedEntries = async (loadPage, pageRecords, normalize, identity, isCurrent) => { const entriesById = new Map(); const seenCursors = new Set(); let cursor = 0; while (true) { const result = await loadPage(cursor); if (!isCurrent()) return null; const [records, nextCursor] = pageRecords(result, cursor); records.forEach((record) => { const entry = normalize(record); if (entry) entriesById.set(identity(entry), entry); }); if (nextCursor == null || seenCursors.has(nextCursor)) break; seenCursors.add(nextCursor); cursor = nextCursor; } return Array.from(entriesById.values()); }; const reactionCollectionIsCurrent = (token) => token === bookmarksState.token && !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction'; const fetchAllGivenReactions = (username, token = bookmarksState.token) => collectPagedEntries( (cursor) => fetchGivenReactionPage(username, cursor), (result) => { const records = reactionPageEntries(result); const next = records.length >= GIVEN_REACTIONS_PAGE_SIZE && records.reduce((lowest, entry) => { const id = Number(entry && entry.id) || 0; return id > 0 && (!lowest || id < lowest) ? id : lowest; }, 0); return [records, next || null]; }, normalizeGivenReaction, (entry) => entry.id, () => reactionCollectionIsCurrent(token) ); const fetchGivenLikePage = (username, offset = 0) => { const params = new URLSearchParams({ username, filter: '1', offset: String(Math.max(0, Number(offset) || 0)), limit: String(GIVEN_LIKES_PAGE_SIZE), }); return fetchJSON(`${BASE}/user_actions.json?${params}`, { key: `likes-given:${username}:${offset}`, priority: POST_REQUEST_PRIORITY.auxiliary, cacheMode: 'default', }); }; const fetchAllGivenLikes = (username, token = bookmarksState.token) => collectPagedEntries( (offset) => fetchGivenLikePage(username, offset), (result, offset) => { const records = Array.isArray(result && result.user_actions) ? result.user_actions : []; return [records, records.length < GIVEN_LIKES_PAGE_SIZE ? null : offset + records.length]; }, normalizeGivenLike, (entry) => entry.postId, () => reactionCollectionIsCurrent(token) ); const reactionRecordEntries = () => bookmarksState.reactionEntries.filter((entry) => (!bookmarksState.reactionFilter || entry.reaction === bookmarksState.reactionFilter) && reactionRecordMatchesSearch(entry, bookmarksState.query)); const reactionAvatarHtml = (entry) => userCardAvatarHtml( persistentAvatarHtml( entry && entry.avatarTemplate, 64, 'ldp-notification-avatar', entry && entry.authorUsername || '?' ) || `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${esc(String(entry && entry.authorUsername || '?').slice(0, 1).toUpperCase())}</span>`, entry && entry.authorUsername ); const bookmarkTypeEntries = () => bookmarksState.type === 'Reaction' ? reactionRecordEntries() : bookmarksState.allEntries.filter((entry) => entry && entry.bookmarkable_type === bookmarksState.type); const renderBookmarksPage = () => { const isReaction = bookmarksState.type === 'Reaction'; const reactionTypeCounts = new Map(); if (isReaction) bookmarksState.reactionEntries.forEach((entry) => { const reaction = String(entry && entry.reaction || ''); if (reaction) reactionTypeCounts.set(reaction, (reactionTypeCounts.get(reaction) || 0) + 1); }); const availableReactionTypes = Array.from(reactionTypeCounts.keys()); if (bookmarksState.reactionFilter && !availableReactionTypes.includes(bookmarksState.reactionFilter)) { bookmarksState.reactionFilter = ''; } bookmarksReactionFilters.hidden = !isReaction || availableReactionTypes.length === 0; bookmarksReactionFilters.innerHTML = isReaction && availableReactionTypes.length ? [`<button class="ldp-reaction-filter${bookmarksState.reactionFilter ? '' : ' active'}" type="button" data-reaction-filter="" aria-pressed="${String(!bookmarksState.reactionFilter)}"><span>全部</span><span class="ldp-reaction-filter-count">${bookmarksState.reactionEntries.length}</span></button>`, ...availableReactionTypes.map((reaction) => { const active = reaction === bookmarksState.reactionFilter; const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`); const count = reactionTypeCounts.get(reaction) || 0; return `<button class="ldp-reaction-filter${active ? ' active' : ''}" type="button" data-reaction-filter="${escAttr(reaction)}" aria-label="只看 ${escAttr(reaction)} 回应,共 ${count} 条" aria-pressed="${String(active)}">${reactionIcon}<span class="ldp-reaction-filter-count">${count}</span></button>`; })].join('') : ''; const typeEntries = bookmarkTypeEntries(); const totalPages = Math.max(1, Math.ceil(typeEntries.length / BOOKMARKS_PAGE_SIZE)); bookmarksState.page = Math.max(0, Math.min(totalPages - 1, bookmarksState.page)); const start = bookmarksState.page * BOOKMARKS_PAGE_SIZE; const entries = typeEntries.slice(start, start + BOOKMARKS_PAGE_SIZE); bookmarksState.entries = entries; bookmarksState.hasNext = bookmarksState.page < totalPages - 1; const availableIds = new Set(isReaction ? [] : typeEntries.map((entry) => Number(entry.id))); bookmarksSelection.forEach((bookmarkId) => { if (!availableIds.has(bookmarkId)) bookmarksSelection.delete(bookmarkId); }); releasePersistentAvatarImages(bookmarksPanel.list); if (isReaction) { bookmarksPanel.list.innerHTML = entries.length ? entries.map((entry) => { const reaction = String(entry.reaction || ''); const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`); const authorLabel = entry.authorUsername ? ` · @${entry.authorUsername}` : ''; return `<div class="ldp-reaction-record ldp-collection-item" data-reaction-record data-reaction-topic-id="${Number(entry.topicId)}" data-reaction-post-number="${Math.max(1, Number(entry.postNumber) || 1)}"> <a class="ldp-notification-item ldp-bookmark-link" data-ldp-preserve-target-post="1" href="${BASE}/t/${Number(entry.topicId)}/${Math.max(1, Number(entry.postNumber) || 1)}?${NATIVE_BYPASS_PARAM}=1"> ${reactionAvatarHtml(entry)} <span class="ldp-notification-copy"> <span class="ldp-notification-title">${esc(entry.title || `帖子 #${entry.topicId}`)}</span> <span class="ldp-notification-meta"><span class="ldp-reaction-record-icon" role="img" aria-label="${escAttr(reaction)} 回应">${reactionIcon}</span>${esc(`回应 · 楼层 #${Math.max(1, Number(entry.postNumber) || 1)}${authorLabel} · ${fmtTime(entry.reactedAt)}`)}</span> </span> </a> </div>`; }).join('') : `<div class="ldp-notification-empty">${bookmarksState.query ? '没有匹配的回应记录' : '暂无回应记录;在楼层下方点回应后会出现在这里。'}</div>`; } else { bookmarksPanel.list.innerHTML = entries.length ? entries.map((entry) => { const bookmarkId = Number(entry.id); const target = bookmarkTarget(entry); const selected = bookmarksSelection.has(bookmarkId); const typeLabel = entry.bookmarkable_type === 'Post' ? `楼层 #${target.postNumber}` : '帖子'; const postsLabel = Number(entry.highest_post_number) > 0 ? ` · ${Number(entry.highest_post_number)} 帖` : ''; const nameLabel = String(entry.name || '').trim() ? ` · ${String(entry.name).trim()}` : ''; return `<div class="ldp-bookmark-item ldp-collection-item${bookmarksState.multi ? ' multi' : ''}${selected ? ' selected' : ''}" data-bookmark-id="${bookmarkId}" data-bookmark-topic-id="${target.topicId}" data-bookmark-post-number="${target.postNumber}"> ${bookmarksState.multi ? `<label class="ldp-bookmark-select ldp-collection-select" data-ldp-tooltip-label="选择这条收藏"> <input class="ldp-bookmark-select-input ldp-collection-select-input" type="checkbox" aria-label="选择《${escAttr(entry.title || '收藏')}》"${selected ? ' checked' : ''}> </label>` : ''} <a class="ldp-notification-item ldp-bookmark-link" data-ldp-preserve-target-post="1" href="${BASE}/t/${target.topicId}/${target.postNumber}?${NATIVE_BYPASS_PARAM}=1"> ${bookmarkAvatarHtml(entry)} <span class="ldp-notification-copy"> <span class="ldp-notification-title">${esc(entry.title || `帖子 #${target.topicId}`)}</span> <span class="ldp-notification-meta">${esc(`${typeLabel}${postsLabel}${nameLabel} · ${fmtTime(entry.created_at)}`)}</span> </span> </a> ${bookmarksState.multi ? '' : `<button class="ldp-bookmark-delete ldp-collection-delete" type="button" aria-label="取消这条收藏">${icon('trash')}</button>`} </div>`; }).join('') : `<div class="ldp-notification-empty">${bookmarksState.query ? `没有匹配的${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏` : `暂无${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`}</div>`; } hydratePersistentAvatarImages(bookmarksPanel.list, POST_REQUEST_PRIORITY.auxiliary); syncTabSelection(bookmarksTabs, 'bookmarkType', bookmarksState.type); bookmarksPanel.search.placeholder = isReaction ? '搜索回应、帖子或用户' : '搜索收藏标题或内容'; bookmarksPanel.search.setAttribute('aria-label', isReaction ? '搜索回应记录' : '搜索收藏'); bookmarksPanel.searchClear.setAttribute('aria-label', isReaction ? '清空回应搜索' : '清空收藏搜索'); const currentIds = isReaction ? [] : entries.map((entry) => Number(entry.id)).filter((id) => id > 0); const scopeIds = bookmarksState.scope === 'all' ? bookmarksState.allIds : currentIds; syncCollectionSelectionControls( bookmarksPanel, bookmarksState, bookmarksSelection, scopeIds, '收藏', typeEntries.length === 0, bookmarksState.scope === 'page' && currentIds.length === 0, isReaction ); bookmarksPanel.pagePrev.disabled = bookmarksState.page <= 0; bookmarksPanel.pageNext.disabled = !bookmarksState.hasNext; bookmarksPanel.pageInfo.textContent = typeEntries.length ? `${bookmarksState.page + 1} / ${totalPages}` : '暂无记录'; positionBookmarksPopover(); }; const fetchAllBookmarkEntries = (query = bookmarksState.query, token = bookmarksState.token) => collectPagedEntries( (page) => fetchBookmarkPage(page, query), (result, page) => { const payload = bookmarkPagePayload(result); return [Array.isArray(payload.bookmarks) ? payload.bookmarks : [], payload.more_bookmarks_url ? page + 1 : null]; }, (entry) => Number(entry && entry.id) > 0 ? entry : null, (entry) => Number(entry.id), () => token === bookmarksState.token && !bookmarksPanel.popover.hidden ); const loadBookmarksPage = async () => { const token = ++bookmarksState.token; const isReaction = bookmarksState.type === 'Reaction'; let reactionSnapshotVisible = isReaction && bookmarksState.reactionLoadedAt > 0 && bookmarksState.reactionUsername === ME_USERNAME; const hydrateReactionIcons = () => { if (reactionEmojiUrls) return; void loadReactionEmojiRegistry().then(() => { if (reactionSnapshotVisible && token === bookmarksState.token && !bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') renderBookmarksPage(); }); }; if (reactionSnapshotVisible) { renderBookmarksPage(); } else { releasePersistentAvatarImages(bookmarksPanel.list); bookmarksPanel.list.innerHTML = `<div class="ldp-notification-empty">正在加载${isReaction ? '回应' : '收藏'}…</div>`; bookmarksPanel.pagePrev.disabled = true; bookmarksPanel.pageNext.disabled = true; } try { if (isReaction) { const username = await ensureMe(); if (!username) throw new Error('登录后才能查看回应记录'); if (bookmarksState.reactionUsername && bookmarksState.reactionUsername !== username) { bookmarksState.reactionEntries = []; bookmarksState.reactionLoadedAt = 0; reactionSnapshotVisible = false; } if (!reactionSnapshotVisible && bookmarksState.reactionLoadedAt > 0 && bookmarksState.reactionUsername === username) { reactionSnapshotVisible = true; renderBookmarksPage(); } if (!reactionSnapshotVisible) { const snapshot = await getGivenReactionSnapshot(username); if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return; if (snapshot) { bookmarksState.reactionEntries = mergeGivenReactionOverrides(snapshot.entries); bookmarksState.reactionLoadedAt = snapshot.storedAt; bookmarksState.reactionUsername = username; reactionSnapshotVisible = true; renderBookmarksPage(); hydrateReactionIcons(); if (snapshot.fresh) return; } } else { hydrateReactionIcons(); if (Date.now() - bookmarksState.reactionLoadedAt <= MINUTE) return; } const [reactionEntries, likeEntries] = await Promise.all([ fetchAllGivenReactions(username, token), fetchAllGivenLikes(username, token), ]); if (!reactionEntries || !likeEntries || token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return; bookmarksState.reactionEntries = mergeGivenReactionOverrides([...likeEntries, ...reactionEntries]); bookmarksState.reactionLoadedAt = Date.now(); bookmarksState.reactionUsername = username; reactionSnapshotVisible = true; setGivenReactionSnapshot(username, bookmarksState.reactionEntries); bookmarksState.multi = false; clearBookmarksSelectionScope(); renderBookmarksPage(); hydrateReactionIcons(); return; } const entries = await fetchAllBookmarkEntries(bookmarksState.query, token); if (!entries || token !== bookmarksState.token || bookmarksPanel.popover.hidden) return; bookmarksState.allEntries = entries; bookmarksState.allIds = null; renderBookmarksPage(); } catch (error) { if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || error && error.name === 'AbortError') return; if (isReaction && reactionSnapshotVisible) return; bookmarksState.entries = []; if (isReaction) bookmarksState.reactionEntries = []; else bookmarksState.allEntries = []; bookmarksState.hasNext = false; bookmarksPanel.list.innerHTML = `<div class="ldp-notification-error">${isReaction ? '回应记录' : '收藏'}加载失败:${esc(error.message)}</div>`; bookmarksPanel.pageInfo.textContent = `第 ${bookmarksState.page + 1} 页`; } }; const applyRemovedBookmarkState = (entry) => { const activeCtx = readerShell.activeContext; const target = bookmarkTarget(entry); if (!activeCtx || Number(activeCtx.topicId) !== target.topicId) return; const type = entry && entry.bookmarkable_type === 'Post' ? 'Post' : 'Topic'; syncReaderBookmarkState(activeCtx, type, entry && entry.bookmarkable_id, false, null, { persist: true }); }; const renderNotificationPage = (result, options = {}) => { const visibleNotifications = sortNotificationsNewestFirst(result.notifications); notificationState.total = result.total; notificationState.hasNext = result.hasNext; releasePersistentAvatarImages(notificationPanel.list); notificationPanel.list.innerHTML = visibleNotifications.length ? renderNotificationList(visibleNotifications) : `<div class="ldp-notification-empty">${options.search ? '本地缓存中没有匹配消息' : '暂无消息'}</div>`; hydrateNotificationAvatars(notificationPanel.list, visibleNotifications); if (notificationPanel.markAll.dataset.busy !== '1') { notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0; } notificationPanel.pagePrev.disabled = notificationState.page === 0; notificationPanel.pageNext.disabled = !notificationState.hasNext; const totalPages = Math.max(1, Math.ceil(notificationState.total / NOTIFICATION_PAGE_SIZE)); notificationPanel.pageInfo.textContent = notificationState.total ? `${notificationState.page + 1} / ${totalPages}` : (options.search ? '本地缓存' : '暂无消息'); positionNotificationsPopover(); }; const renderNotificationSearchPage = () => { const matches = cachedNotificationsForGroup(notificationState.group) .filter((notification) => notificationMatchesSearch(notification, notificationState.query)); const totalPages = Math.max(1, Math.ceil(matches.length / NOTIFICATION_PAGE_SIZE)); notificationState.page = Math.max(0, Math.min(totalPages - 1, notificationState.page)); const start = notificationState.page * NOTIFICATION_PAGE_SIZE; renderNotificationPage({ notifications: matches.slice(start, start + NOTIFICATION_PAGE_SIZE), total: matches.length, hasNext: notificationState.page < totalPages - 1, }, { search: true }); }; const loadNotificationPage = async (groupKey, page) => { const token = ++notificationState.token; notificationState.group = Object.prototype.hasOwnProperty.call(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all'; notificationState.page = Math.max(0, Number(page) || 0); syncTabSelection(notificationTabs, 'notificationGroup', notificationState.group); if (notificationState.query) { if (notificationQueuedRequestKey) currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey); notificationQueuedRequestKey = ''; renderNotificationSearchPage(); return; } const cacheKey = notificationPageCacheKey(notificationState.group, notificationState.page); const requestKey = `notifications:${cacheKey}`; if (notificationQueuedRequestKey && notificationQueuedRequestKey !== requestKey) { currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey); } const cached = getCachedNotificationPage(cacheKey); const cachedIsFresh = cached && cachedNotificationPageIsFresh(cacheKey); if (cached) { renderNotificationPage(cached); if (cachedIsFresh) { notificationQueuedRequestKey = ''; return; } } notificationQueuedRequestKey = requestKey; if (!cached) { releasePersistentAvatarImages(notificationPanel.list); notificationPanel.list.innerHTML = '<div class="ldp-notification-empty">正在加载消息…</div>'; notificationPanel.pagePrev.disabled = true; notificationPanel.pageNext.disabled = true; } try { const result = await fetchNotificationPage(notificationState.group, notificationState.page, { force: !!cached }); if (token !== notificationState.token || notificationsPopover.hidden) return; renderNotificationPage(result); } catch (error) { if (error && error.name === 'AbortError') return; if (token !== notificationState.token || notificationsPopover.hidden) return; if (cached) return; notificationPanel.list.innerHTML = `<div class="ldp-notification-error">消息加载失败:${esc(error.message)}</div>`; notificationPanel.pageInfo.textContent = `第 ${notificationState.page + 1} 页`; } finally { if (notificationQueuedRequestKey === requestKey) notificationQueuedRequestKey = ''; } }; const readerUsesFullPage = () => !readerWorkspace.isEmbedded() && (directTopicRoute ? PREFS.fullPage : readerWorkspace.mode() === 'fullpage'); const activeReaderProfileName = () => { if (readerWorkspace.isEmbedded()) { const dimensions = readerWorkspace.dimensions(); return readerProfileForDimensions(dimensions.width, dimensions.height); } if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return 'mobile'; return readerUsesFullPage() ? 'full' : 'popup'; }; const syncProfileSharingControls = () => { profileSharingInputs.forEach((input) => { const category = input.dataset.profileSharingCategory; const field = input.dataset.profileSharingField; input.checked = category === 'font' ? fontDraftSettingShared(field) : category === 'layout' ? layoutDraftSettingShared(field) : category === 'appearance' ? appearanceDraftSettingShared(field) : readerProfileSettingShared(category, field); }); }; const syncProfileRoutingNotes = () => { const embedded = readerWorkspace.isEmbedded(); const dimensions = embedded ? readerWorkspace.dimensions() : null; const width = dimensions ? Math.round(dimensions.width) : 0; const height = dimensions ? Math.round(dimensions.height) : 0; const ratio = height > 0 ? width / height : 0; const comparison = ratio <= EMBEDDED_MOBILE_MAX_ASPECT_RATIO ? '≤' : '>'; const profile = activeReaderProfileName(); const directionIcon = embedded && readerWorkspace.mode() === 'embed-right' ? 'panelRight' : 'panelLeft'; profileRoutingNotes.forEach((note) => { const category = note.dataset.profileRouting; const sharedCount = category === 'font' ? READER_PROFILE_SHARING_FIELDS.font.filter(fontDraftSettingShared).length : category === 'layout' ? LAYOUT_REGION_KEYS.filter(layoutDraftSettingShared).length : category === 'appearance' ? APPEARANCE_PROFILE_VALUE_KEYS.filter(appearanceDraftSettingShared).length : readerProfileSharingCount(category); if (sharedCount) { note.classList.toggle('is-embedded', embedded); note.classList.add('is-shared'); note.innerHTML = embedded ? `<span class="ldp-reader-profile-line"><span class="ldp-reader-profile-indicator">${icon(directionIcon)}<strong>嵌入态 → ${READER_PROFILE_LABELS[profile]}</strong></span>` + `<span class="ldp-reader-profile-metrics">${width} × ${height} · 宽高比 ${ratio.toFixed(2)} ${comparison} 3:4</span></span>` + `<span class="ldp-reader-profile-line"><span class="ldp-reader-profile-indicator">${icon('link2')}<strong>已共享 ${sharedCount} 项</strong></span>` + `<span class="ldp-reader-profile-metrics">其余${READER_PROFILE_SHARING_LABELS[category]}设置仍按形态分别保存。</span></span>` : `<span class="ldp-reader-profile-indicator">${icon('link2')}<strong>已共享 ${sharedCount} 项</strong></span>` + `<span class="ldp-reader-profile-metrics">其余${READER_PROFILE_SHARING_LABELS[category]}设置仍按形态分别保存。</span>`; return; } note.classList.remove('is-shared'); if (!embedded) { note.classList.remove('is-embedded'); note.textContent = '点每项右侧的链状按钮可单独共享;嵌入不大于 3:4 使用“移动”,更宽时使用“浮窗”。'; return; } note.classList.add('is-embedded'); note.innerHTML = `<span class="ldp-reader-profile-indicator">${icon(directionIcon)}<strong>嵌入态 → ${READER_PROFILE_LABELS[profile]}</strong></span>` + `<span class="ldp-reader-profile-metrics">${width} × ${height} · 宽高比 ${ratio.toFixed(2)} ${comparison} 3:4</span>`; }); }; const syncReaderBackgroundMode = () => { setPageScrollLocked(readerUsesFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH); }; let imageSettingsProfile = activeReaderProfileName(); let layoutSettingsProfile = activeReaderProfileName(); let layoutDraftProfiles = currentLayoutProfiles(); let sharedLayoutDraft = currentSharedLayoutProfile(); let layoutSharingDraft = Object.assign({}, currentReaderProfileSharing().layout); let layoutDraftEditedRegion = ''; let fontSettingsProfile = activeReaderProfileName(); let fontSettingsScope = 'interface'; let fontDraftProfiles = currentFontProfiles(); let sharedFontDraft = currentSharedFontProfile(); let fontSharingDraft = Object.assign({}, currentReaderProfileSharing().font); let appearanceSettingsProfile = activeReaderProfileName(); let appearanceDraftProfiles = currentAppearanceProfiles(); let sharedAppearanceDraft = currentSharedAppearanceProfile(); let appearanceSharingDraft = Object.assign({}, currentReaderProfileSharing().appearance); let jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); const jumpHighlightDraftChanged = () => { const saved = jumpHighlightConfigFromPrefs(PREFS); return JUMP_HIGHLIGHT_SETTING_FIELDS.some((field) => jumpHighlightDraft[field.key] !== saved[field.key]); }; const fontDraftSettingShared = (field) => READER_PROFILE_SHARING_FIELDS.font.includes(field) && fontSharingDraft[field] === true; const fontSharingDraftChanged = () => { const saved = currentReaderProfileSharing().font; return READER_PROFILE_SHARING_FIELDS.font.some((field) => fontSharingDraft[field] !== saved[field]); }; const layoutDraftSettingShared = (field) => LAYOUT_REGION_KEYS.includes(field) && layoutSharingDraft[field] === true; const layoutDraftSharingEnabled = () => LAYOUT_REGION_KEYS.some(layoutDraftSettingShared); const appearanceDraftSettingShared = (field) => APPEARANCE_PROFILE_VALUE_KEYS.includes(field) && appearanceSharingDraft[field] === true; const categorySharingDraftChanged = (category, draft) => { const saved = currentReaderProfileSharing()[category]; return READER_PROFILE_SHARING_FIELDS[category].some((field) => draft[field] !== saved[field]); }; const mergeFontDraftProfile = (profileValues, sharedValues) => { const values = mergeSharedProfileValues(profileValues, sharedValues, FONT_PROFILE_VALUE_KEYS, (name) => fontDraftSettingShared(fontProfileSharingField(name))); return normalizeFontProfile(values, profileValues); }; const currentFontDraft = () => mergeFontDraftProfile(fontDraftProfiles[fontSettingsProfile], sharedFontDraft); const setCurrentFontDraft = (values) => { [fontDraftProfiles[fontSettingsProfile], sharedFontDraft] = splitSharedProfileValues( values, fontDraftProfiles[fontSettingsProfile], sharedFontDraft, FONT_PROFILE_VALUE_KEYS, (name) => fontDraftSettingShared(fontProfileSharingField(name)) ); }; const currentFontDefaults = () => mergeFontDraftProfile(FONT_PROFILE_DEFAULTS[fontSettingsProfile], FONT_PROFILE_DEFAULTS.full); const currentLayoutDraft = () => resolveSharedLayoutRatios( layoutDraftProfiles[layoutSettingsProfile], sharedLayoutDraft, layoutSharingDraft, layoutDraftEditedRegion ); const setCurrentLayoutDraft = (values) => { [layoutDraftProfiles[layoutSettingsProfile], sharedLayoutDraft] = splitSharedProfileValues( values, layoutDraftProfiles[layoutSettingsProfile], sharedLayoutDraft, LAYOUT_REGION_KEYS, layoutDraftSettingShared ); }; const currentLayoutDefaults = () => resolveSharedLayoutRatios( LAYOUT_PROFILE_DEFAULTS[layoutSettingsProfile], LAYOUT_PROFILE_DEFAULTS.full ); const currentAppearanceDraft = () => mergeSharedProfileValues( appearanceDraftProfiles[appearanceSettingsProfile], sharedAppearanceDraft, APPEARANCE_PROFILE_VALUE_KEYS, appearanceDraftSettingShared ); const setCurrentAppearanceDraft = (values) => { [appearanceDraftProfiles[appearanceSettingsProfile], sharedAppearanceDraft] = splitSharedProfileValues( values, appearanceDraftProfiles[appearanceSettingsProfile], sharedAppearanceDraft, APPEARANCE_PROFILE_VALUE_KEYS, appearanceDraftSettingShared ); }; const currentAppearanceDefaults = () => mergeSharedProfileValues( APPEARANCE_PROFILE_DEFAULTS[appearanceSettingsProfile], APPEARANCE_PROFILE_DEFAULTS.full, APPEARANCE_PROFILE_VALUE_KEYS, appearanceDraftSettingShared ); const currentSettingsProfileLabel = (profile) => READER_PROFILE_LABELS[profile]; const applyReaderVisualSettings = () => { applyImageScale(); applyLayoutRatios(); applyFontScales(); applyAppearanceColors(); }; const syncOpenSettingsProfileControls = (includeWindowControls = false) => { if (settingsPopover.hidden) return; imageSettingsProfile = activeReaderProfileName(); layoutSettingsProfile = activeReaderProfileName(); layoutDraftEditedRegion = ''; fontSettingsProfile = activeReaderProfileName(); appearanceSettingsProfile = activeReaderProfileName(); syncImageScaleControls(); syncLayoutControls(); syncFontControls(); syncAppearanceControls(); syncProfileRoutingNotes(); if (includeWindowControls) syncReaderWindowControls(); }; const syncLayoutBtn = () => { const fullPage = readerUsesFullPage(); layoutBtn.hidden = !directTopicRoute && !fullPage; layoutBtn.innerHTML = `${icon(fullPage ? 'minimize2' : 'maximize2')}<span>${fullPage ? '浮窗' : '全页'}</span>`; layoutBtn.setAttribute('aria-label', fullPage ? '切换到浮窗' : '切换到全页'); overlay.classList.toggle('ldp-fullpage', fullPage); readerWindow.syncMode(); syncReaderBackgroundMode(); }; const onReaderWorkspaceChange = () => { syncLayoutBtn(); syncReaderPlacementUi(); syncReaderThemeSurfaces(); applyReaderVisualSettings(); const activeContext = readerShell.activeContext; if (activeContext && activeContext.nativeComposerWindow) activeContext.nativeComposerWindow.syncLayer(); positionHeaderPopovers(); syncOpenSettingsProfileControls(true); }; if (!reusingShell) { syncLayoutBtn(); overlay.addEventListener('ldp-reader-workspace-change', onReaderWorkspaceChange); readerShell.onReaderWorkspaceChange = onReaderWorkspaceChange; layoutBtn.addEventListener('click', () => { if (directTopicRoute) setPref('fullPage', !PREFS.fullPage); else if (readerUsesFullPage()) readerWorkspace.setMode('floating'); else return; syncLayoutBtn(); syncOpenSettingsProfileControls(); applyReaderVisualSettings(); positionHeaderPopovers(); const activeContext = readerShell.activeContext; if (!activeContext) return; activeContext.streamListOffset = NaN; activeContext.streamNeedsRepair = true; scheduleStreamWindowSync(activeContext); }); } const applyImageScale = () => { const profile = activeReaderProfileName(); overlay.style.setProperty('--ldp-image-zoom', String(currentImageScalePercent(profile) / 100)); }; const fontSettingsSection = overlay.querySelector('[data-settings-panel="font"]'); const layoutSettingsSection = overlay.querySelector('[data-settings-panel="layout"]'); const appearanceSettingsSection = overlay.querySelector('[data-settings-panel="appearance"]'); const appearanceValuesChanged = () => { const values = currentAppearanceDraft(); const saved = currentAppearanceProfile(appearanceSettingsProfile); return categorySharingDraftChanged('appearance', appearanceSharingDraft) || APPEARANCE_PROFILE_VALUE_KEYS.some((key) => values[key] !== saved[key]); }; const activeAppearanceColors = () => { const profile = activeReaderProfileName(); const previewing = !settingsPopover.hidden && !appearanceSettingsSection.hidden; if (!previewing) return currentAppearanceProfile(profile); const profileValues = appearanceSettingsProfile === profile ? appearanceDraftProfiles[profile] : currentAppearanceProfiles()[profile]; const values = Object.assign({}, profileValues); APPEARANCE_PROFILE_VALUE_KEYS.forEach((key) => { if (appearanceDraftSettingShared(key)) values[key] = sharedAppearanceDraft[key]; }); return values; }; const themeAdjustedDefaultColor = (value, lightDefault, darkDefault) => ( resolvedReaderThemeMode() === 'dark' && value === lightDefault ? darkDefault : value ); const activeFontScaleProfile = () => { const profile = activeReaderProfileName(); const previewing = !settingsPopover.hidden && !fontSettingsSection.hidden; if (!previewing) return currentFontProfile(profile); const profileValues = fontSettingsProfile === profile ? fontDraftProfiles[profile] : currentFontProfiles()[profile]; return mergeFontDraftProfile(profileValues, sharedFontDraft); }; const applyFontScales = () => { const profile = activeReaderProfileName(); const values = activeFontScaleProfile(); overlay.dataset.fontProfile = profile; applyInterfaceFontScale(overlay.style, values.interface); applyInterfaceFontScale(READER_PORTAL_HOST?.style, values.interface); applyReaderFontAppearance(overlay.style, values); applyReaderFontAppearance(READER_PORTAL_HOST?.style, values); applyReaderFontAppearance(document.documentElement.style, values); const postFontSize = fontScalePixels(POST_FONT_BASE_PX, values.post); overlay.style.setProperty('--ldp-post-font-size', postFontSize); READER_PORTAL_HOST?.style.setProperty('--ldp-post-font-size', postFontSize); applyComposerFontScale(values.composer); const activeContext = readerShell.activeContext; if (activeContext && activeContext.nativeComposerWindow) { activeContext.nativeComposerWindow.syncFont(values); } }; const applyLayoutRatios = () => { const profile = activeReaderProfileName(); const previewing = !settingsPopover.hidden && !layoutSettingsSection.hidden; const profileValues = previewing && layoutSettingsProfile === profile ? layoutDraftProfiles[profile] : currentLayoutProfiles()[profile]; const ratios = previewing ? resolveSharedLayoutRatios( profileValues, sharedLayoutDraft, layoutSharingDraft, layoutSettingsProfile === profile ? layoutDraftEditedRegion : '' ) : currentLayoutRatios(profile); overlay.dataset.layoutProfile = profile; LAYOUT_REGION_KEYS.forEach((name) => { overlay.style.setProperty(`--ldp-layout-${name}`, `${ratios[name]}%`); }); }; const applyInteractionColors = () => { const surfaces = new Set([READER_PORTAL_HOST, overlay]); if (READER_PORTAL_CONTEXT_BODY) { Array.from(READER_PORTAL_CONTEXT_BODY.children).forEach((node) => surfaces.add(node)); } document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => surfaces.add(node)); surfaces.forEach((node) => applyAppearanceInteractionColors(node, activeAppearanceColors())); }; const applyZebraAppearance = () => { const values = activeAppearanceColors(); overlay.style.setProperty('--ldp-zebra-color', themeAdjustedDefaultColor( values.zebraColor, ZEBRA_COLOR_DEFAULT, READER_THEME_VARIABLES.dark['--ldp-zebra-color'] )); overlay.style.setProperty('--ldp-zebra-radius', `${values.zebraRadius}px`); }; const applyListZebraColor = () => { const value = themeAdjustedDefaultColor( activeAppearanceColors().listZebraColor, LIST_ZEBRA_COLOR_DEFAULT, READER_THEME_VARIABLES.dark['--ldp-zebra-color'] ); if (!readerWorkspace.isEmbedded()) { document.documentElement.style.removeProperty('--ldp-reader-list-zebra-color'); return; } document.documentElement.style.setProperty('--ldp-reader-list-zebra-color', value); }; const applyLineAppearance = (type, defaultColor, emphasis) => { const values = activeAppearanceColors(); const key = `${type}Line`; const property = `--ldp-${type}-line`; const value = values[`${key}Color`]; const width = values[`${key}Width`]; overlay.style.setProperty(`${property}-color`, themeAdjustedDefaultColor( value, defaultColor, READER_THEME_VARIABLES.dark[`${property}-color`] )); overlay.style.setProperty(`${property}-width`, `${width}px`); overlay.style.setProperty(`${property}-emphasis-width`, `${width * emphasis}px`); if (type === 'reply') { overlay.style.setProperty(`${property}-secondary-width`, `${width + 0.5}px`); overlay.style.setProperty(`${property}-radius`, `${values.replyLineRadius}px`); overlay.querySelectorAll('.ldp-children').forEach(syncNestedReplyBranches); const activeContext = readerShell.activeContext; if (activeContext && activeContext.floorPreview && activeContext.floorPreview.isOpen()) { scheduleFloorPreviewSync(activeContext); } return; } if (type !== 'divider') return; if (!readerWorkspace.isEmbedded() || value === DIVIDER_LINE_COLOR_DEFAULT) { document.documentElement.style.removeProperty('--ldp-divider-line-color'); } else { document.documentElement.style.setProperty('--ldp-divider-line-color', value); } if (!readerWorkspace.isEmbedded() || width === DIVIDER_LINE_WIDTH_DEFAULT) { document.documentElement.style.removeProperty('--ldp-divider-line-width'); } else { document.documentElement.style.setProperty('--ldp-divider-line-width', `${width}px`); } }; const applyReplyLineAppearance = () => applyLineAppearance('reply', REPLY_LINE_COLOR_DEFAULT, 2); const applyQuoteLineAppearance = () => applyLineAppearance('quote', QUOTE_LINE_COLOR_DEFAULT, 5); const applyDividerLineAppearance = () => applyLineAppearance('divider', DIVIDER_LINE_COLOR_DEFAULT, 2); const applyFloorPreviewAppearance = () => { const values = activeAppearanceColors(); overlay.style.setProperty('--ldp-floor-preview-color', values.floorPreviewColor); overlay.style.setProperty('--ldp-floor-preview-height', `${values.floorPreviewHeight}px`); const activeContext = readerShell.activeContext; if (activeContext && activeContext.floorPreview && activeContext.floorPreview.isOpen()) { scheduleFloorPreviewSync(activeContext); } }; const applyAppearanceColors = () => { applyInteractionColors(); applyZebraAppearance(); applyListZebraColor(); applyReplyLineAppearance(); applyQuoteLineAppearance(); applyDividerLineAppearance(); applyFloorPreviewAppearance(); }; const applyJumpHighlightSettings = (config = jumpHighlightConfigFromPrefs(PREFS)) => { overlay.style.setProperty('--ldp-jump-highlight-color', config.color); overlay.style.setProperty('--ldp-jump-highlight-radius', `${config.radius}px`); overlay.style.setProperty('--ldp-jump-highlight-border-width', `${config.borderWidth}px`); overlay.style.setProperty('--ldp-jump-highlight-duration', `${jumpHighlightDurationMs(config)}ms`); overlay.style.setProperty('--ldp-jump-highlight-count', String(config.count)); }; overlay._ldpApplyThemeDependentColors = applyAppearanceColors; const syncRangeVisual = (input) => { const min = Number(input.min) || 0; const max = Number(input.max) || 100; const value = Number(input.value); const progress = max > min && Number.isFinite(value) ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100)) : 0; input.style.setProperty('--ldp-range-progress', `${progress}%`); }; const syncImageScaleControls = () => { syncTabSelection(imageProfileTabs, 'imageProfile', imageSettingsProfile, true); const scale = currentImageScalePercent(imageSettingsProfile); imageScaleRange.value = String(scale); imageScaleValue.textContent = `${scale}%`; syncRangeVisual(imageScaleRange); const defaults = IMAGE_PROFILE_DEFAULTS[imageSettingsProfile]; const defaultScale = normalizeImageScalePercent( defaults.preset === 'custom' ? defaults.custom : defaults.preset, 100 ); imageScaleReset.disabled = scale === defaultScale; lightboxOriginalByDefaultInput.checked = PREFS.lightboxOriginalByDefault === true; lightboxCommentsExpandedInput.checked = PREFS.lightboxCommentsExpandedByDefault === true; lightboxDescriptionExpandedInput.checked = PREFS.lightboxDescriptionExpanded === true; }; let pageFontFamilies = []; let localFontFamilies = []; let localFontAccessState = 'idle'; let localFontAccessRequest = null; let activeFontFamilyScope = 'interface'; const fontFamilyManualEditing = new Set(); const fontScopeConfig = (scope = activeFontFamilyScope) => scope === 'host' ? HOST_FONT_CONFIG : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface; const fontValuesForScope = (scope = activeFontFamilyScope, readerValues = currentFontDraft()) => scope === 'host' ? PREFS : readerValues; const activeFontFamilyTrigger = () => Array.from(fontFamilyTriggers) .find((trigger) => trigger.dataset.fontScope === activeFontFamilyScope); const fontFamilySort = (left, right) => String(left.name || '').localeCompare( String(right.name || ''), 'zh-CN', { sensitivity: 'base', numeric: true } ); const fontFamilySearchText = (...values) => values.flatMap((value) => pinyinSearchForms(value)).join(' '); const collectAvailableFontFamilies = (fonts, searchFields, fallbackNameField = '') => { const entries = new Map(); Array.from(fonts || []).forEach((font) => { const name = normalizeCustomFontFamily(font && (font.family || font[fallbackNameField])); if (!name) return; const key = name.toLocaleLowerCase(); const entry = entries.get(key) || { name, search: new Set() }; searchFields.forEach((field) => { if (font && font[field]) entry.search.add(String(font[field])); }); entries.set(key, entry); }); return Array.from(entries.values()).map((entry) => ({ name: entry.name, search: Array.from(entry.search), })).sort(fontFamilySort); }; const collectPageFontFamilies = () => { try { return collectAvailableFontFamilies( document.fonts, ['family', 'style', 'weight', 'stretch'] ); } catch (e) { return []; } }; const fontFamilyDisplayLabel = (values = fontValuesForScope(), scope = activeFontFamilyScope) => { const config = fontScopeConfig(scope); const family = normalizeFontFamily(values[config.family]); if (family !== 'custom') return FONT_FAMILY_OPTIONS[family].label; return normalizeCustomFontFamily(values[config.customFamily]) || '手动输入字体名'; }; const syncFontFamilySelection = (values = currentFontDraft()) => { fontFamilyTriggers.forEach((trigger) => { const scope = trigger.dataset.fontScope; const value = trigger.querySelector('.ldp-font-family-value'); value.textContent = fontFamilyDisplayLabel(fontValuesForScope(scope, values), scope); trigger.dataset.ldpTooltipLabel = value.textContent; }); const config = fontScopeConfig(); const selectedValues = fontValuesForScope(activeFontFamilyScope, values); const selectedFamily = normalizeFontFamily(selectedValues[config.family]); const selectedName = normalizeSearchText(selectedValues[config.customFamily]); fontFamilyOptions.querySelectorAll('.ldp-font-family-option').forEach((option) => { const selected = option.dataset.fontKind === 'preset' ? selectedFamily === option.dataset.fontValue : selectedFamily === 'custom' && normalizeSearchText(option.dataset.fontValue) === selectedName; option.setAttribute('aria-selected', String(selected)); }); }; const createFontFamilyOption = ({ kind, value, label, source, stack, search = [] }, values) => { const option = document.createElement('button'); const config = fontScopeConfig(); const selected = kind === 'preset' ? normalizeFontFamily(values[config.family]) === value : normalizeFontFamily(values[config.family]) === 'custom' && normalizeSearchText(values[config.customFamily]) === normalizeSearchText(value); option.className = 'ldp-font-family-option ldp-picker-option'; option.type = 'button'; option.dataset.fontKind = kind; option.dataset.fontValue = value; option.dataset.searchText = fontFamilySearchText(label, source, ...search); option.setAttribute('role', 'option'); option.setAttribute('aria-selected', String(selected)); option.innerHTML = `<span class="ldp-font-family-option-copy ldp-picker-option-copy"><strong>${esc(label)}</strong><small>${esc(source)}</small></span>` + '<span class="ldp-font-family-preview">Aa 字</span>'; option.querySelector('.ldp-font-family-preview').style.fontFamily = stack; return option; }; const appendFontFamilyGroup = (fragment, title, items, values) => { if (!items.length) return; const group = document.createElement('div'); group.className = 'ldp-font-family-group'; group.setAttribute('role', 'group'); group.setAttribute('aria-label', title); const heading = document.createElement('div'); heading.className = 'ldp-font-family-group-title'; heading.textContent = `${title} · ${items.length}`; group.append(heading); items.forEach((item) => group.append(createFontFamilyOption(item, values))); fragment.append(group); }; const fontFamilyStatusCopy = () => { if (localFontAccessState === 'loading') return '正在读取浏览器允许访问的本机字体…'; if (localFontAccessState === 'unsupported') return '当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。'; if (localFontAccessState === 'denied') return '未获得本机字体读取权限;仍可选择页面字体或手动输入。'; if (localFontAccessState === 'error') return '本机字体读取失败;仍可选择页面字体或手动输入。'; if (localFontAccessState === 'ready' && !localFontFamilies.length) return '浏览器没有返回可用的本机字体;仍可手动输入字体名。'; return ''; }; const applyFontFamilySearch = () => { const query = normalizeSearchText(fontFamilySearch.value); let visibleCount = 0; fontFamilyOptions.querySelectorAll('.ldp-font-family-group').forEach((group) => { let groupVisible = 0; group.querySelectorAll('.ldp-font-family-option').forEach((option) => { const visible = !query || String(option.dataset.searchText || '').includes(query); option.hidden = !visible; if (visible) groupVisible += 1; }); group.hidden = groupVisible === 0; visibleCount += groupVisible; }); fontFamilyEmpty.hidden = visibleCount !== 0; }; const renderFontFamilyOptions = () => { const values = fontValuesForScope(); const config = fontScopeConfig(); const namedItem = (item, source) => ({ kind: 'named', value: item.name, label: item.name, source, stack: `${JSON.stringify(item.name)},${FONT_FAMILY_OPTIONS.system.stack}`, search: item.search, }); const presetItems = Object.entries(FONT_FAMILY_OPTIONS) .filter(([value]) => value !== 'custom') .map(([value, option]) => ({ kind: 'preset', value, label: option.label, source: value === 'site' ? '站点样式' : value === FONT_FAMILY_DEFAULT ? '默认字体' : '阅读器预设', stack: value === 'site' ? resolvedSiteFontFamily() : option.stack, search: [option.stack], })); const pageItems = pageFontFamilies.map((item) => namedItem(item, '页面已加载')); const localItems = localFontFamilies.map((item) => namedItem(item, '本机已安装')); const currentName = normalizeCustomFontFamily(values[config.customFamily]); const availableNames = new Set([...pageFontFamilies, ...localFontFamilies] .map((item) => normalizeSearchText(item.name))); const currentItems = normalizeFontFamily(values[config.family]) === 'custom' && currentName && !availableNames.has(normalizeSearchText(currentName)) ? [namedItem({ name: currentName, search: [currentName] }, '当前手动输入')] : []; const fragment = document.createDocumentFragment(); appendFontFamilyGroup(fragment, '预设', presetItems, values); appendFontFamilyGroup(fragment, '页面已加载', pageItems, values); appendFontFamilyGroup(fragment, '本机已安装', localItems, values); appendFontFamilyGroup(fragment, '当前选择', currentItems, values); fontFamilyOptions.replaceChildren(fragment); const statusCopy = fontFamilyStatusCopy(); fontFamilySourceStatus.textContent = statusCopy; fontFamilySourceStatus.hidden = !statusCopy; applyFontFamilySearch(); }; const positionFontFamilyMenu = () => { const trigger = activeFontFamilyTrigger(); if (fontFamilyMenu.hidden || !trigger || !trigger.isConnected) return; const margin = 12; const gap = 6; const triggerRect = trigger.getBoundingClientRect(); const width = Math.min(360, Math.max(260, window.innerWidth - margin * 2)); const left = Math.min( Math.max(margin, triggerRect.left), Math.max(margin, window.innerWidth - width - margin) ); fontFamilyMenu.style.width = `${Math.round(width)}px`; fontFamilyMenu.style.top = `${margin}px`; const menuHeight = fontFamilyMenu.getBoundingClientRect().height; const belowTop = triggerRect.bottom + gap; const aboveTop = triggerRect.top - menuHeight - gap; const top = belowTop + menuHeight <= window.innerHeight - margin ? belowTop : aboveTop >= margin ? aboveTop : Math.max(margin, window.innerHeight - menuHeight - margin); const coversTrigger = top <= triggerRect.top && top + menuHeight >= triggerRect.bottom; const positionedLeft = coversTrigger ? Math.max(margin, left - 2) : left; fontFamilyMenu.style.left = `${Math.round(positionedLeft)}px`; fontFamilyMenu.style.top = `${Math.round(top)}px`; }; const closeFontFamilyMenu = (restoreFocus = false) => { if (fontFamilyMenu.hidden) return; const trigger = activeFontFamilyTrigger(); fontFamilyMenu.hidden = true; fontFamilyTriggers.forEach((button) => button.setAttribute('aria-expanded', 'false')); if (restoreFocus && trigger && trigger.isConnected) trigger.focus(); }; const requestLocalFontFamilies = () => { if (localFontAccessState !== 'idle') return localFontAccessRequest; if (typeof globalThis.queryLocalFonts !== 'function') { localFontAccessState = 'unsupported'; renderFontFamilyOptions(); return null; } localFontAccessState = 'loading'; renderFontFamilyOptions(); try { localFontAccessRequest = Promise.resolve(globalThis.queryLocalFonts()).then((fonts) => { localFontFamilies = collectAvailableFontFamilies( fonts, ['family', 'fullName', 'postscriptName', 'style'], 'fullName' ); localFontAccessState = 'ready'; }).catch((error) => { localFontAccessState = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError') ? 'denied' : 'error'; }).finally(() => { localFontAccessRequest = null; renderFontFamilyOptions(); positionFontFamilyMenu(); }); } catch (error) { localFontAccessState = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError') ? 'denied' : 'error'; renderFontFamilyOptions(); } return localFontAccessRequest; }; const openFontFamilyMenu = (scope) => { if (FONT_PICKER_SCOPE_KEYS.includes(scope)) activeFontFamilyScope = scope; fontFamilyTriggers.forEach((button) => button.setAttribute( 'aria-expanded', String(button.dataset.fontScope === activeFontFamilyScope) )); pageFontFamilies = collectPageFontFamilies(); fontFamilySearch.value = ''; renderFontFamilyOptions(); fontFamilyMenu.hidden = false; positionFontFamilyMenu(); requestLocalFontFamilies(); fontFamilySearch.focus({ preventScroll: true }); if (document.fonts && document.fonts.ready) { void Promise.resolve(document.fonts.ready).then(() => { pageFontFamilies = collectPageFontFamilies(); renderFontFamilyOptions(); positionFontFamilyMenu(); }); } }; const fontScaleSettings = [ { input: interfaceFontScaleRange, output: interfaceFontScaleValue, key: 'interface', normalize: normalizeFontScale }, { input: fontScaleRange, output: fontScaleValue, key: 'post', normalize: normalizeFontScale }, { input: composerFontScaleRange, output: composerFontScaleValue, key: 'composer', normalize: normalizeFontScale }, ]; const syncFontControls = () => { const values = currentFontDraft(); const savedValues = currentFontProfile(fontSettingsProfile); const defaultValues = currentFontDefaults(); const profileLabel = currentSettingsProfileLabel(fontSettingsProfile); hostEmbeddedSizeInputs.forEach((input) => { const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize]; if (!config) return; const scale = normalizeHostEmbeddedSizeScale(PREFS[config.pref]); input.value = String(scale); syncRangeVisual(input); const output = Array.from(hostEmbeddedSizeValues) .find((value) => value.dataset.hostEmbedSizeValue === input.dataset.hostEmbedSize); if (output) { output.value = `${scale}%`; output.textContent = `${scale}%`; } const detail = input.dataset.hostEmbedSize === 'labelCard' ? '卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。' : input.dataset.hostEmbedSize === 'stats' ? '右下角的回复、最近回复、活跃和回应四组信息会一起变化。' : ''; setSettingRowHelp(input, `调整宿主嵌入态主题列表的${config.label}大小,默认 ${config.defaultScale}%。${detail}修改后立即保存。`); }); syncTabSelection(fontProfileTabs, 'fontProfile', fontSettingsProfile, true); syncTabSelection(fontScopeTabs, 'fontScopeTab', fontSettingsScope, true); fontScopePanels.forEach((panel) => { panel.hidden = panel.dataset.fontScopePanel !== fontSettingsScope; }); syncFontFamilySelection(values); FONT_PICKER_SCOPE_KEYS.forEach((scope) => { const host = scope === 'host'; const scopeValues = host ? PREFS : values; const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues; const config = fontScopeConfig(scope); const customInput = Array.from(fontFamilyCustomInputs).find((input) => input.dataset.fontScope === scope); const weightSelect = Array.from(fontWeightSelects).find((select) => select.dataset.fontScope === scope); const colorInput = Array.from(fontColorInputs).find((input) => input.dataset.fontScope === scope); const colorValue = Array.from(fontColorValues).find((value) => value.dataset.fontScope === scope); const color = normalizeHexColor(scopeValues[config.color], scopeDefaults[config.color]); if (normalizeFontFamily(scopeValues[config.family]) !== 'custom') fontFamilyManualEditing.delete(scope); customInput.value = scopeValues[config.customFamily]; customInput.hidden = normalizeFontFamily(scopeValues[config.family]) !== 'custom' || !fontFamilyManualEditing.has(scope); weightSelect.value = String(scopeValues[config.weight]); colorInput.value = color || READER_THEME_VARIABLES[resolvedReaderThemeMode()]['--primary']; colorValue.textContent = color ? color.toUpperCase() : '跟随主题'; const trigger = Array.from(fontFamilyTriggers).find((button) => button.dataset.fontScope === scope); if (host) { setSettingRowHelp(trigger, `选择嵌入态 topic 列表标题、Label Card 和右下角信息共享的字体。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`); setSettingRowHelp(weightSelect, `调整嵌入态 topic 列表标题、Label Card 和右下角信息共享的字重。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。修改后立即保存。`); setSettingRowHelp(colorInput, '调整嵌入态 topic 列表标题、Label Card 和右下角信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。'); } else { setSettingRowHelp(trigger, `选择${profileLabel}${config.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”。`); setSettingRowHelp(weightSelect, `调整${profileLabel}${config.label}文字的粗细。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。`); setSettingRowHelp(colorInput, `调整${profileLabel}${config.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${config.label}文字,不改变链接、错误和状态等语义颜色。`); } }); fontScaleSettings.forEach(({ input, output, key }) => { input.value = String(values[key]); output.textContent = `${values[key]}%`; syncRangeVisual(input); }); setSettingRowHelp(interfaceFontScaleRange, `调整${profileLabel}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${defaultValues.interface}%;不会改变帖子正文和回复输入文字。`); setSettingRowHelp(fontScaleRange, `调整${profileLabel}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${defaultValues.post}%;拖动时实时预览,确认应用后保存。`); setSettingRowHelp(composerFontScaleRange, `调整${profileLabel}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${defaultValues.composer}%;拖动时实时预览,确认应用后保存。`); fontFieldResetButtons.forEach((button) => { const host = button.dataset.hostFontReset != null; const profileField = button.dataset.fontFieldReset; const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField)); const config = fontScopeConfig(scope); const field = host ? config[button.dataset.hostFontReset] : profileField; const scopeValues = host ? PREFS : values; const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues; const defaultValue = scopeDefaults[field]; const familyField = field === config.family; const weightField = field === config.weight; const colorField = field === config.color; const fieldLabel = `${host ? '' : profileLabel}${config.label}${familyField ? '字体' : weightField ? '字重' : colorField ? '颜色' : '字号'}`; const defaultLabel = familyField ? FONT_FAMILY_OPTIONS[defaultValue].label : weightField ? String(defaultValue) : colorField ? '跟随主题' : `${defaultValue}%`; button.disabled = familyField ? scopeValues[config.family] === scopeDefaults[config.family] && scopeValues[config.customFamily] === scopeDefaults[config.customFamily] : scopeValues[field] === defaultValue; button.querySelector('span').textContent = `默认 ${defaultLabel}`; button.setAttribute('aria-label', `恢复${fieldLabel}默认值 ${defaultLabel}`); }); const changed = fontSharingDraftChanged() || FONT_PROFILE_VALUE_KEYS.some((name) => values[name] !== savedValues[name]); fontStatus.textContent = changed ? `${profileLabel}字体正在实时预览;点击“确认应用”后保存。` : `${profileLabel}字体当前配置已应用。`; fontStatus.classList.toggle('balanced', !changed); const hostFontAtDefault = Object.keys(HOST_FONT_DEFAULTS).every((name) => PREFS[name] === HOST_FONT_DEFAULTS[name]); fontResetBtn.disabled = hostFontAtDefault && FONT_PROFILE_VALUE_KEYS.every((name) => values[name] === defaultValues[name] ); fontApplyBtn.disabled = !changed; syncSettingsDraftIndicators(); }; const syncLayoutControls = () => { const ratios = currentLayoutDraft(); const savedRatios = currentLayoutRatios(layoutSettingsProfile); const defaultRatios = currentLayoutDefaults(); const profileLabel = currentSettingsProfileLabel(layoutSettingsProfile); const remaining = Math.max(0, roundLayoutRatio(100 - layoutRatioTotal(ratios))); syncTabSelection(layoutProfileTabs, 'layoutProfile', layoutSettingsProfile, true); layoutRatioInputs.forEach((input) => { const name = input.dataset.layoutRegion; if (!LAYOUT_REGION_KEYS.includes(name)) return; input.max = '100'; input.value = String(ratios[name]); syncRangeVisual(input); }); layoutRatioValues.forEach((valueEl) => { const name = valueEl.dataset.layoutValue; if (!LAYOUT_REGION_KEYS.includes(name)) return; valueEl.textContent = `${Number(ratios[name].toFixed(1))}%`; }); layoutTotal.classList.toggle('warning', remaining > 0); layoutTotal.classList.toggle('balanced', remaining <= 0); const changed = categorySharingDraftChanged('layout', layoutSharingDraft) || LAYOUT_REGION_KEYS.some((name) => Math.abs(ratios[name] - savedRatios[name]) >= 0.01); layoutTotal.textContent = remaining > 0 ? `${profileLabel}布局还剩 ${Number(remaining.toFixed(1))}% 未分配,必须分配完才能确认。` : changed ? `${profileLabel}布局已分配 100%,正在实时预览;点击“确认应用”后保存。` : `${profileLabel}布局当前配置已应用。`; layoutResetBtn.disabled = LAYOUT_REGION_KEYS.every((name) => Math.abs(ratios[name] - defaultRatios[name]) < 0.01 ); layoutApplyBtn.disabled = remaining > 0 || !changed; syncSettingsDraftIndicators(); }; const syncReaderWindowControls = () => { const state = readerWindow.getState(); const compact = window.innerWidth <= READER_WINDOW_COMPACT_WIDTH; const floating = !readerWorkspace.isEmbedded() && !readerUsesFullPage(); const geometry = `${Math.round(state.width)} × ${Math.round(state.height)} · (${Math.round(state.left)}, ${Math.round(state.top)})${readerWindow.isPinned() ? ' · 已置顶' : ''}${readerWindow.isLocked() ? ' · 已锁定' : ''}`; readerWindowWidthInput.max = String(Math.max(READER_WINDOW_MIN_WIDTH, window.innerWidth - READER_WINDOW_MARGIN * 2)); readerWindowHeightInput.max = String(Math.max(READER_WINDOW_MIN_HEIGHT, window.innerHeight - READER_WINDOW_MARGIN * 2)); readerWindowXInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerWidth - READER_WINDOW_MARGIN - state.width)); readerWindowYInput.min = String(READER_WINDOW_MARGIN); readerWindowYInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerHeight - READER_WINDOW_MARGIN - state.height)); readerWindowWidthInput.value = String(Math.round(state.width)); readerWindowHeightInput.value = String(Math.round(state.height)); readerWindowXInput.value = String(Math.round(state.left)); readerWindowYInput.value = String(Math.round(state.top)); readerWindowPinInput.checked = readerWindow.isPinned(); readerWindowLockInput.checked = readerWindow.isLocked(); [readerWindowWidthInput, readerWindowHeightInput, readerWindowXInput, readerWindowYInput, readerWindowPinInput, readerWindowLockInput].forEach((input) => { input.disabled = compact; }); readerWindowStatus.textContent = compact ? '移动端使用独立全屏布局,不影响桌面浮窗。' : floating ? `${geometry}${readerWindow.isLocked() ? '' : ' · 可拖动缩放'}` : `${readerWorkspace.isEmbedded() ? '当前为嵌入阅读' : '当前为全屏阅读'};以下配置将在切换到浮窗后生效。浮窗:${geometry}`; readerWindowResetBtn.disabled = compact || readerWindow.isDefault(); }; const appearanceApplyByGroup = { interaction: applyInteractionColors, zebra: applyZebraAppearance, 'list-zebra': applyListZebraColor, 'reply-line': applyReplyLineAppearance, 'quote-line': applyQuoteLineAppearance, 'divider-line': applyDividerLineAppearance, 'floor-preview': applyFloorPreviewAppearance, }; const appearanceControlSettings = appearanceSettingElements.map((field) => Object.assign({}, field, { apply: appearanceApplyByGroup[field.applyGroup], })); const syncAppearanceValueControls = () => { const values = currentAppearanceDraft(); appearanceControlSettings.forEach(({ input, output, key }) => { const value = values[key]; const range = input.type === 'range'; input.value = range ? String(value) : value; output.textContent = range ? `${value}px` : value; if (range) syncRangeVisual(input); }); }; const syncColorResetButtons = () => { const values = currentAppearanceDraft(); const defaults = currentAppearanceDefaults(); colorResetButtons.forEach((button) => { const key = button.dataset.colorReset; button.disabled = !defaults[key] || values[key] === defaults[key]; }); }; const syncAppearanceControls = () => { const profileLabel = currentSettingsProfileLabel(appearanceSettingsProfile); syncTabSelection(appearanceProfileTabs, 'appearanceProfile', appearanceSettingsProfile, true); syncAppearanceValueControls(); syncColorResetButtons(); const changed = appearanceValuesChanged(); appearanceStatus.textContent = changed ? `${profileLabel}外观正在实时预览;点击“确认应用”后保存。` : `${profileLabel}外观当前配置已应用。`; appearanceStatus.classList.toggle('balanced', !changed); appearanceApplyBtn.disabled = !changed; syncSettingsDraftIndicators(); }; let performanceSettingsChanged = false; const syncPerformanceControls = () => { const config = currentPerformanceConfig(); const preset = performancePresetForConfig(config); performancePresetButtons.forEach((button) => { const active = button.dataset.performancePreset === preset; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); }); performanceInputs.forEach((input) => { const name = input.dataset.performanceKey; const value = config[name]; if (value == null) return; input.value = PERFORMANCE_LIMITS[name] && PERFORMANCE_LIMITS[name].integer ? String(value) : String(Number(value.toFixed(2))); }); performanceResetBtn.disabled = preset === 'balanced'; const presetLabels = { low: '省资源', balanced: '均衡', high: '低等待', custom: '自定义' }; const nextConfig = `主楼层 ${config.pageSize} 条/批 · DOM ≤ ${config.streamMaxItems} 个 · 楼中楼提前 ${config.nestedPrefetchViewports} 屏 · 调度目标 ${config.requestRateTarget}%`; const requestState = currentReaderRequestScheduler().snapshot(); const windowStatus = `窗口预算 ${requestState.shortWindowBudget}/10 秒、${requestState.longWindowBudget}/分钟`; const runtimeStatus = requestState.endpointCoolingDown && !requestState.coolingDown ? ` 当前隔离 ${requestState.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1, Math.ceil(requestState.endpointCooldownRemaining / 1000))} 秒后允许再次尝试;其他请求继续按${windowStatus}运行。` : requestState.coolingDown ? ` 当前会话命中${requestState.lastRateLimitWindow || '动态'}限流,正在退避,约 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))} 秒后继续;${windowStatus}。` : requestState.learnedRateLimit ? ` 当前会话已按最近边界调节;${windowStatus}${requestState.minInterval > requestState.configuredMinInterval ? `,最小启动间隔 ${requestState.minInterval}ms` : ''},稳定承压后继续小步提速,并发按需最高 ${requestState.maxConcurrent} 路。` : ` 当前会话正常自适应;${windowStatus},稳定承压后小步上探,并发按需最高 ${requestState.maxConcurrent} 路。`; const savedStatus = performanceSettingsChanged ? `已保存为${presetLabels[preset] || '自定义'}:${nextConfig};下次打开阅读器生效。` : `下次打开采用${presetLabels[preset] || '自定义'}:${nextConfig}。`; performanceStatus.textContent = savedStatus + runtimeStatus; }; const formatRequestFlowBytes = (value) => { const bytes = Math.max(0, Number(value) || 0); if (bytes < 1024) return `${Math.round(bytes)} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; }; const formatRequestFlowDuration = (value) => { const milliseconds = Math.max(0, Number(value) || 0); if (milliseconds < 10) return `${milliseconds.toFixed(1)}ms`; if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`; if (milliseconds < 60000) return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)}s`; return `${(milliseconds / 60000).toFixed(1)}min`; }; const formatRequestFlowTimestamp = (value) => { const date = new Date(Number(value) || 0); const clock = date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }); return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`; }; const requestFlowSourceLabel = (source) => REQUEST_FLOW_SOURCE_LABELS[source] || '其他'; const requestFlowPriorityLabel = (priority) => { if (!Number.isFinite(priority)) return ''; if (priority <= POST_REQUEST_PRIORITY.topic) return '核心'; if (priority === POST_REQUEST_PRIORITY.scroll) return '滚动'; if (priority === POST_REQUEST_PRIORITY.visible) return '可见'; if (priority === POST_REQUEST_PRIORITY.nested) return '预取'; if (priority === POST_REQUEST_PRIORITY.auxiliary) return '辅助'; return '后台'; }; const requestFlowWaitReasonLabel = (reason) => REQUEST_FLOW_WAIT_REASON_LABELS[reason] || '调度'; const requestFlowIssue = (event, at = requestFlowNow()) => { if (!event) return null; const networkDuration = event.pending ? Math.max(0, at - event.startedAt) : Math.max(0, Number(event.duration) || 0); const queueDuration = Math.max(0, Number(event.permitWait) || 0); if (event.controlOnly) { const reason = String(event.controlReason || event.waitReason || 'cancelled'); if (reason === 'queue-limit') return { level: 'warning', kind: '队列已满', detail: '辅助请求在发出前被队列上限丢弃' }; if (reason === 'endpoint-cooldown') return { level: 'warning', kind: '端点熔断', detail: '同一异常 URL 在冷却期内未再次发出,不影响其他请求' }; if (reason === 'rate-limit-priority' || reason === 'cooldown') { return { level: 'warning', kind: '限流让位', detail: '低优先级请求在发出前为限流恢复让位' }; } if (reason === 'context-close') return { level: 'warning', kind: '页面取消', detail: '阅读器上下文结束,请求未发出' }; return { level: 'warning', kind: '调度取消', detail: `请求未发出(${requestFlowWaitReasonLabel(event.waitReason)})` }; } if (event.status === 429) { return { level: 'danger', kind: '429 限流', detail: event.rateLimitCode || '服务器拒绝了当前请求速率' }; } if (event.status >= 500) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '服务器或上游服务返回错误' }; if (event.status >= 400) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '请求参数、权限或目标状态异常' }; if (event.error) { const aborted = /abort/i.test(event.error); return { level: aborted ? 'warning' : 'danger', kind: aborted ? '中止/超时' : '网络错误', detail: event.error }; } if (event.pending && event.type !== 'realtime' && networkDuration >= POST_REQUEST_TIMEOUT) { return { level: 'danger', kind: '请求卡住', detail: `网络阶段已持续 ${formatRequestFlowDuration(networkDuration)}` }; } if (queueDuration >= 1000) { return { level: 'warning', kind: '排队过久', detail: `${requestFlowWaitReasonLabel(event.waitReason)}等待 ${formatRequestFlowDuration(queueDuration)}`, }; } if (!event.pending && event.type !== 'realtime' && networkDuration >= REQUEST_FLOW_SLOW_MS) { return { level: 'warning', kind: '响应偏慢', detail: `网络阶段耗时 ${formatRequestFlowDuration(networkDuration)}` }; } return null; }; const requestFlowTimingLabel = (event, at = requestFlowNow()) => { const queueDuration = Math.max(0, Number(event.permitWait) || 0); const dispatchDuration = Math.max(0, Number(event.dispatchDuration) || 0); const networkDuration = event.pending ? Math.max(0, at - event.startedAt) : Math.max(0, Number(event.duration) || 0); if (event.controlOnly) return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}未发出`; return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}${dispatchDuration >= 1 ? `放行 ${formatRequestFlowDuration(dispatchDuration)} · ` : ''}网 ${formatRequestFlowDuration(networkDuration)}`; }; const requestFlowSnapshot = (at = requestFlowNow()) => { pruneRequestFlowEvents(at); const session = REQUEST_FLOW_EVENTS.slice(); const recent60 = session.filter((event) => !event.controlOnly && event.startedAt >= at - 60000); const recent10 = recent60.filter((event) => event.startedAt >= at - REQUEST_FLOW_TRACE_WINDOW_MS); const binEnd = Math.ceil(at / 100) * 100; const binStart = binEnd - REQUEST_FLOW_TRACE_WINDOW_MS; const bins100 = Array.from({ length: REQUEST_FLOW_TRACE_WINDOW_MS / 100 }, () => 0); const typeStats = new Map(); const sourceCounts = { reader: 0, host: 0, browser: 0 }; let transfer = 0; const durations = []; recent60.forEach((event) => { const binIndex = Math.floor((event.startedAt - binStart) / 100); if (binIndex >= 0 && binIndex < bins100.length) bins100[binIndex]++; const stat = typeStats.get(event.type) || { type: event.type, count: 0, completed: 0, durations: [], issues: 0 }; stat.count++; if (!event.pending) { stat.completed++; if (event.duration > 0 && event.type !== 'realtime') stat.durations.push(event.duration); if (event.duration > 0 && event.type !== 'realtime') durations.push(event.duration); } if (requestFlowIssue(event, at)) stat.issues++; typeStats.set(event.type, stat); if (Object.hasOwn(sourceCounts, event.source)) sourceCounts[event.source]++; transfer += event.size; }); durations.sort((a, b) => a - b); const p95Index = durations.length ? Math.min(durations.length - 1, Math.ceil(durations.length * .95) - 1) : -1; const latestLimit = session.slice().reverse().find((event) => event.status === 429 || event.retryAfter || event.rateLimitCode || event.serverLimit); const recentIssues = session .filter((event) => (Number(event.endedAt) || event.startedAt) >= at - 60000) .map((event) => ({ event, issue: requestFlowIssue(event, at) })) .filter((entry) => entry.issue); const traceEvents = session.filter((event) => { const lifecycleStart = Number(event.queuedAt) || event.startedAt; const lifecycleEnd = event.pending ? at : Number(event.endedAt) || event.startedAt; return lifecycleStart <= at && lifecycleEnd >= at - REQUEST_FLOW_TRACE_WINDOW_MS; }); const sortedTypeStats = Array.from(typeStats.values()).map((stat) => { stat.durations.sort((a, b) => a - b); const index = stat.durations.length ? Math.min(stat.durations.length - 1, Math.ceil(stat.durations.length * .95) - 1) : -1; return { ...stat, p95: index >= 0 ? stat.durations[index] : 0 }; }).sort((a, b) => b.count - a.count); return { at, session, recent60, recent10, traceEvents, typeStats: sortedTypeStats, recentIssues, sourceCounts, transfer, peak100: Math.max(0, ...bins100), p95: p95Index >= 0 ? durations[p95Index] : 0, completed: durations.length, active: session.filter((event) => event.pending).length, latestLimit, }; }; const requestFlowBottleneckInfo = (snapshot, schedulerState) => { const latest429 = snapshot.recent60.slice().reverse().find((event) => event.status === 429); if (latest429 || (schedulerState && schedulerState.coolingDown)) { const event = latest429 || snapshot.latestLimit; const typeLabel = REQUEST_FLOW_TYPE_META[event && event.type] && REQUEST_FLOW_TYPE_META[event.type].label || '请求'; const wait = event && event.retryAfter ? `,服务器要求等待 ${event.retryAfter} 秒` : schedulerState && schedulerState.cooldownRemaining ? `,约 ${Math.max(1, Math.ceil(schedulerState.cooldownRemaining / 1000))} 秒后继续` : ''; return { level: 'danger', state: '服务器限流', detail: `${typeLabel}在最近一分钟收到 429${wait};未启动的辅助和后台请求会让位。` }; } const latestDanger = snapshot.recentIssues.slice().reverse().find((entry) => entry.issue.level === 'danger'); if (latestDanger) { const { event, issue } = latestDanger; const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type; const caller = event.callSite ? `,发起点 ${event.callSite}` : ''; return { level: 'danger', state: issue.kind, detail: `${typeLabel} ${event.method} ${event.path}:${issue.detail}${caller}。`, }; } if (schedulerState && Math.max(schedulerState.queued, schedulerState.globalQueued || 0) >= 3) { const wait = Math.max(0, Number(schedulerState.nextPermitDelay) || 0); const reason = schedulerState.blockingReason === '10s' ? '10 秒窗口' : schedulerState.blockingReason === '60s' ? '60 秒窗口' : schedulerState.blockingReason === 'interval' ? '最小启动间隔' : ''; const windowDetail = reason ? `;正在等待${reason}${wait ? `,约 ${Math.max(1, Math.ceil(wait / 1000))} 秒后放行` : ''}` : ''; return { level: 'warning', state: '阅读器排队', detail: `全局 ${schedulerState.globalActive || schedulerState.active} 路执行、${Math.max(schedulerState.queued, schedulerState.globalQueued || 0)} 条排队${windowDetail};宿主操作、目标楼层、正文和可见楼中楼会先启动。`, }; } if (snapshot.completed >= 5 && snapshot.p95 >= 1800) { return { level: 'warning', state: '响应偏慢', detail: `最近一分钟完成请求的 P95 响应时间为 ${formatRequestFlowDuration(snapshot.p95)},瓶颈更可能在网络或服务器响应,而不是启动间隔。`, }; } const resourceCount = snapshot.typeStats .filter((stat) => ['avatar', 'media', 'asset'].includes(stat.type)) .reduce((sum, stat) => sum + stat.count, 0); if (snapshot.recent60.length >= 12 && resourceCount / snapshot.recent60.length >= .7 && snapshot.transfer >= 4 * 1024 * 1024) { return { level: 'warning', state: '资源占用', detail: `头像、图片和静态资源占最近一分钟请求的 ${Math.round(resourceCount / snapshot.recent60.length * 100)}%,已传输 ${formatRequestFlowBytes(snapshot.transfer)}。`, }; } if (!snapshot.recent60.length) { return { level: 'normal', state: '等待采样', detail: '打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。' }; } return { level: 'normal', state: '节奏正常', detail: `最近一分钟 ${snapshot.recent60.length} 次请求,${snapshot.active} 次进行中,P95 ${snapshot.p95 ? formatRequestFlowDuration(snapshot.p95) : '暂无'};当前未发现明显限流或排队瓶颈。`, }; }; const requestFlowTraceMarkup = (snapshot) => { const windowStart = snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS; const position = (value) => Math.max(0, Math.min(100, (Number(value) - windowStart) / REQUEST_FLOW_TRACE_WINDOW_MS * 100)); const width = (start, end) => Math.max(0, position(end) - position(start)); const sources = [ { source: 'reader', label: '阅读器' }, { source: 'host', label: '宿主' }, { source: 'browser', label: '资源' }, ]; return sources.map(({ source, label }) => { const layerEnds = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]; const events = snapshot.traceEvents.filter((event) => event.source === source) .sort((a, b) => (Number(a.queuedAt) || a.startedAt) - (Number(b.queuedAt) || b.startedAt)) .slice(-80); const items = events.map((event) => { const lifecycleStart = Number(event.queuedAt) || event.startedAt; const lifecycleEnd = event.pending ? snapshot.at : Number(event.endedAt) || event.startedAt; let layer = layerEnds.findIndex((end) => end <= lifecycleStart); if (layer < 0) layer = layerEnds.indexOf(Math.min(...layerEnds)); layerEnds[layer] = lifecycleEnd; const top = 3 + layer * 10; const issue = requestFlowIssue(event, snapshot.at); const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type; const priorityLabel = requestFlowPriorityLabel(event.priority); const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成'; const detail = [ `${formatRequestFlowTimestamp(event.startedAt)} · ${event.method} ${event.path}`, `${requestFlowSourceLabel(event.source)} / ${typeLabel} / ${status}`, requestFlowTimingLabel(event, snapshot.at), priorityLabel && `${priorityLabel}优先级`, event.attempt > 1 && `第 ${event.attempt} 次尝试`, event.callSite && `发起点 ${event.callSite}`, ].filter(Boolean).join(' · '); const wireStart = Math.max(windowStart, event.startedAt); const wireEnd = Math.min(snapshot.at, Math.max(event.startedAt, lifecycleEnd)); const wire = event.controlOnly ? '' : `<i class="ldp-request-flow-trace-wire" data-request-flow-type="${event.type}" data-pending="${event.pending}" data-ldp-tooltip-label="${escAttr(detail)}" style="--ldp-request-flow-left:${position(wireStart).toFixed(3)}%;--ldp-request-flow-width:${width(wireStart, wireEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>`; const queueEnd = Math.min(snapshot.at, Number(event.permittedAt) || event.startedAt); const queue = lifecycleStart < queueEnd - .5 && queueEnd >= windowStart ? `<i class="ldp-request-flow-trace-queue" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(lifecycleStart)} → ${formatRequestFlowTimestamp(queueEnd)} · ${requestFlowWaitReasonLabel(event.waitReason)}排队 ${formatRequestFlowDuration(event.permitWait)}`)}" style="--ldp-request-flow-left:${position(Math.max(windowStart, lifecycleStart)).toFixed(3)}%;--ldp-request-flow-width:${width(Math.max(windowStart, lifecycleStart), queueEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>` : ''; const permit = queue && !event.controlOnly ? `<i class="ldp-request-flow-trace-permit" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(queueEnd)} · 调度放行`)}" style="--ldp-request-flow-left:${position(queueEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>` : ''; const anomalyAt = Math.min(snapshot.at, Math.max(windowStart, lifecycleEnd)); const anomalyPosition = Math.max(.6, Math.min(99.4, position(anomalyAt))); const anomaly = issue ? `<i class="ldp-request-flow-trace-anomaly" data-level="${issue.level}" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(anomalyAt)} · ${issue.kind} · ${event.path} · ${issue.detail}`)}" style="--ldp-request-flow-left:${anomalyPosition.toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>` : ''; return queue + permit + wire + anomaly; }).join(''); return `<div class="ldp-request-flow-trace-lane" data-request-flow-source="${source}"> <span class="ldp-request-flow-trace-label">${label}</span> <span class="ldp-request-flow-trace-track">${items}</span> </div>`; }).join(''); }; let requestFlowTimer = 0; const updateRequestFlowHtml = (element, html) => { if (!element || element.matches(':hover') || element.innerHTML === html) return; const scrollTop = element.scrollTop; element.innerHTML = html; if (scrollTop) element.scrollTop = scrollTop; }; const syncRequestFlowControls = () => { if (!requestFlowChart || !requestFlowSection) return; const snapshot = requestFlowSnapshot(); let schedulerState = null; try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch (error) {} const recent429 = snapshot.recent60.filter((event) => event.status === 429).length; const metricValues = { rate10: `${snapshot.recent10.length}次 · ${(snapshot.recent10.length / 10).toFixed(1)}/秒`, rate60: `${snapshot.recent60.length}次 · ${(snapshot.recent60.length / 60).toFixed(1)}/秒`, peak: `${snapshot.peak100}次/100ms`, transfer: formatRequestFlowBytes(snapshot.transfer), issues: `${snapshot.recentIssues.length}次${recent429 ? ` · 429 ${recent429}` : ''}`, }; requestFlowMetrics.forEach((element) => { element.textContent = metricValues[element.dataset.requestFlowMetric] || '—'; }); const queued10 = snapshot.recent10.filter((event) => event.permitWait > .5).length; const globalUsage = schedulerState ? `全局 ${schedulerState.globalInstances} 实例 · 执行 ${schedulerState.globalActive} · 排队 ${schedulerState.globalQueued} · 共享上限 ${schedulerState.globalMaxConcurrent} 路 · 间隔 ${schedulerState.globalMinInterval}ms${schedulerState.coordinationMode === 'best-effort' ? ' · 协调降级' : ''}` : `本页阅读器 ${snapshot.sourceCounts.reader} · 宿主 ${snapshot.sourceCounts.host}`; const readerWindowUsage = schedulerState ? ` · 窗口 ${schedulerState.shortWindowCount}/${schedulerState.shortWindowBudget} · ${schedulerState.longWindowCount}/${schedulerState.longWindowBudget}` : ''; requestFlowWindow.textContent = `近 10 秒 ${snapshot.recent10.length} 请求 · 排队 ${queued10} · 异常 ${snapshot.recentIssues.filter((entry) => entry.event.startedAt >= snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS).length} · ${globalUsage}${readerWindowUsage}`; updateRequestFlowHtml(requestFlowChart, requestFlowTraceMarkup(snapshot)); const visibleTypes = snapshot.typeStats.slice(0, 8); const typeLegend = visibleTypes.length ? visibleTypes.map((stat) => `<span data-request-flow-type="${stat.type}"><i class="ldp-request-flow-dot"></i>${esc(REQUEST_FLOW_TYPE_META[stat.type]?.label || stat.type)}</span>`).join('') : '<span data-request-flow-type="other"><i class="ldp-request-flow-dot"></i>等待请求</span>'; updateRequestFlowHtml(requestFlowLegend, `<span><i class="ldp-request-flow-queue-key"></i>排队</span><span><i class="ldp-request-flow-warning-key"></i>慢/久候</span><span><i class="ldp-request-flow-danger-key"></i>错误/限流</span>${typeLegend}`); const bottleneck = requestFlowBottleneckInfo(snapshot, schedulerState); requestFlowBottleneck.dataset.level = bottleneck.level; requestFlowBottleneckState.textContent = bottleneck.state; requestFlowBottleneckDetail.textContent = bottleneck.detail; if (requestFlowAnomalyWindow) { requestFlowAnomalyWindow.textContent = snapshot.recentIssues.length ? `最近 60 秒 ${snapshot.recentIssues.length} 个 · 红色为失败/限流` : '最近 60 秒未发现异常'; } if (requestFlowAnomalies) { const latestIssues = snapshot.recentIssues.slice(-8).reverse(); updateRequestFlowHtml(requestFlowAnomalies, latestIssues.length ? latestIssues.map(({ event, issue }) => { const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type; const caller = event.callSite || `${event.transport} 发起`; const detail = `${requestFlowSourceLabel(event.source)} / ${typeLabel} · ${requestFlowTimingLabel(event, snapshot.at)} · ${issue.detail} · 发起点 ${caller}`; return `<div class="ldp-request-flow-anomaly-row" data-level="${issue.level}" data-request-flow-type="${event.type}" data-ldp-tooltip-label="${escAttr(`${event.method} ${event.path} · ${detail}`)}"> <span class="ldp-request-flow-anomaly-time">${esc(formatRequestFlowTimestamp(event.endedAt || event.startedAt))}</span> <span class="ldp-request-flow-anomaly-kind">${esc(issue.kind)}</span> <span class="ldp-request-flow-anomaly-detail"><strong>${esc(`${event.method} ${event.path}`)}</strong><small>${esc(detail)}</small></span> </div>`; }).join('') : '<div class="ldp-request-flow-empty">最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。</div>'); } updateRequestFlowHtml(requestFlowTypes, snapshot.typeStats.length ? snapshot.typeStats.map((stat) => { const p95 = stat.p95 ? formatRequestFlowDuration(stat.p95) : '—'; return `<div class="ldp-request-flow-type-row" data-request-flow-type="${stat.type}"> <span class="ldp-request-flow-type-name"><i class="ldp-request-flow-dot"></i><strong>${esc(REQUEST_FLOW_TYPE_META[stat.type]?.label || stat.type)}</strong></span> <span>${stat.count} 次</span><span>${p95}</span><span>${stat.issues || '—'}</span> </div>`; }).join('') : '<div class="ldp-request-flow-empty">还没有可统计的请求。</div>'); const recentLog = snapshot.session.slice(-60).reverse(); updateRequestFlowHtml(requestFlowLog, recentLog.length ? recentLog.map((event) => { const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '—'; const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type; const priorityLabel = requestFlowPriorityLabel(event.priority); const issue = requestFlowIssue(event, snapshot.at); const caller = event.callSite || `${event.transport} 发起`; const title = `${event.method} ${event.path}${priorityLabel ? ` · ${priorityLabel}优先级` : ''}${event.attempt > 1 ? ` · 第 ${event.attempt} 次尝试` : ''} · ${requestFlowTimingLabel(event, snapshot.at)} · 发起点 ${caller}`; return `<div class="ldp-request-flow-log-row"${issue ? ` data-level="${issue.level}"` : ''} data-request-flow-type="${event.type}" data-ldp-tooltip-label="${escAttr(title)}"> <span>${esc(formatRequestFlowTimestamp(event.startedAt))}</span> <span class="ldp-request-flow-source">${requestFlowSourceLabel(event.source)}</span> <span class="ldp-request-flow-type-name"><i class="ldp-request-flow-dot"></i>${esc(typeLabel)}</span> <span class="ldp-request-flow-status${event.status >= 400 || event.error ? ' is-error' : ''}">${status}</span> <span class="ldp-request-flow-timing">${esc(requestFlowTimingLabel(event, snapshot.at))}</span> <span class="ldp-request-flow-path">${esc(`${event.path} ← ${caller}`)}</span> </div>`; }).join('') : '<div class="ldp-request-flow-empty">打开帖子或操作宿主页面后,请求会按时间出现在这里。</div>'); const latestLimit = snapshot.latestLimit; if (latestLimit && latestLimit.status === 429) { const typeLabel = REQUEST_FLOW_TYPE_META[latestLimit.type]?.label || '请求'; const details = [ latestLimit.rateLimitCode && `错误码 ${latestLimit.rateLimitCode}`, latestLimit.retryAfter && `Retry-After ${latestLimit.retryAfter} 秒`, ].filter(Boolean).join(','); requestFlowObserved.textContent = `本页最近观测:${typeLabel}收到 429${details ? `(${details})` : ''}。`; } else if (latestLimit && latestLimit.serverLimit) { requestFlowObserved.textContent = `服务器最近返回:上限 ${latestLimit.serverLimit}${latestLimit.serverRemaining ? `,剩余 ${latestLimit.serverRemaining}` : ''}${latestLimit.serverReset ? `,重置 ${latestLimit.serverReset}` : ''}。`; } else if (schedulerState && schedulerState.learnedRateLimit) { requestFlowObserved.textContent = `所有实例已共享历史限流与有效策略:${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`; } else if (schedulerState) { requestFlowObserved.textContent = `当前未收到 429 或服务器上限头;所有实例共同使用 ${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`; } }; const stopRequestFlowUpdates = () => { if (requestFlowTimer) clearInterval(requestFlowTimer); requestFlowTimer = 0; }; const startRequestFlowUpdates = () => { stopRequestFlowUpdates(); if (document.visibilityState !== 'visible') return; syncRequestFlowControls(); requestFlowTimer = setInterval(syncRequestFlowControls, 1000); }; const RESOURCE_MONITOR_RETENTION_MS = 10 * 60 * 1000; const RESOURCE_MONITOR_MAX_EVENTS = 1200; const resourceMonitorState = readerShell.resourceMonitorState || { timer: 0, longTaskObserver: null, longTasksAvailable: false, samples: [], }; const RESOURCE_MONITOR_OBSERVER_KEYS = [ 'longTaskObserver', 'longFrameObserver', 'resourceObserver', 'mutationObserver', 'readerMutationObserver', 'hostMutationObserver', ]; const disconnectResourceMonitorObservers = (keys = RESOURCE_MONITOR_OBSERVER_KEYS) => keys.forEach((key) => { if (resourceMonitorState[key] && typeof resourceMonitorState[key].disconnect === 'function') { resourceMonitorState[key].disconnect(); } resourceMonitorState[key] = null; }); if (!Array.isArray(resourceMonitorState.samples)) resourceMonitorState.samples = []; if (!Array.isArray(resourceMonitorState.events)) resourceMonitorState.events = []; if (!Array.isArray(resourceMonitorState.visibilityTimeline)) resourceMonitorState.visibilityTimeline = []; if (!Array.isArray(resourceMonitorState.memorySamples)) resourceMonitorState.memorySamples = []; if (!resourceMonitorState.memory || typeof resourceMonitorState.memory !== 'object') { resourceMonitorState.memory = { bytes: null, status: 'waiting', nextAt: 0, pending: false }; } if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer); disconnectResourceMonitorObservers(); resourceMonitorState.timer = 0; const previousResourceMonitorVisibility = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1]; if (previousResourceMonitorVisibility && previousResourceMonitorVisibility.state !== 'stopped') { resourceMonitorState.visibilityTimeline.push({ at: requestFlowNow(), state: 'stopped' }); } resourceMonitorState.collecting = false; resourceMonitorState.eventSequence = Number(resourceMonitorState.eventSequence) || 0; resourceMonitorState.lastPruneAt = Number(resourceMonitorState.lastPruneAt) || 0; readerShell.resourceMonitorState = resourceMonitorState; const resourceMonitorLongTasksSupported = typeof window.PerformanceObserver === 'function' && (!Array.isArray(window.PerformanceObserver.supportedEntryTypes) || window.PerformanceObserver.supportedEntryTypes.includes('longtask')); const resourceMonitorLongFramesSupported = typeof window.PerformanceObserver === 'function' && Array.from(window.PerformanceObserver.supportedEntryTypes || []).includes('long-animation-frame'); const resourceMonitorNow = () => requestFlowNow(); const resourceMonitorTimestamp = (at) => { const date = new Date(Number(at) || 0); const clock = date.toLocaleTimeString('zh-CN', { hour12: false }); return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`; }; const resourceMonitorDuration = (value) => { const duration = Math.max(0, Number(value) || 0); return `${duration < 100 ? duration.toFixed(1) : Math.round(duration)}ms`; }; const resourceMonitorPrune = (at = resourceMonitorNow()) => { const cutoff = at - RESOURCE_MONITOR_RETENTION_MS; resourceMonitorState.samples = resourceMonitorState.samples .filter((entry) => Number(entry.at) >= cutoff) .slice(-600); resourceMonitorState.memorySamples = resourceMonitorState.memorySamples .filter((entry) => Number(entry.at) >= cutoff) .slice(-60); resourceMonitorState.events = resourceMonitorState.events .filter((entry) => Number(entry.at) >= cutoff) .slice(-RESOURCE_MONITOR_MAX_EVENTS); resourceMonitorState.visibilityTimeline = resourceMonitorState.visibilityTimeline .filter((entry, index, entries) => Number(entry.at) >= cutoff || index === entries.length - 1) .slice(-240); }; const resourceMonitorPushEvent = (event) => { const at = Number(event && event.at); if (!Number.isFinite(at)) return; resourceMonitorState.events.push(Object.assign({ id: ++resourceMonitorState.eventSequence, visibility: 'unknown', scope: 'shared', kind: 'event', duration: 0, detail: '', basis: '', }, event, { at })); if (resourceMonitorState.events.length > RESOURCE_MONITOR_MAX_EVENTS) { resourceMonitorState.events.splice(0, resourceMonitorState.events.length - RESOURCE_MONITOR_MAX_EVENTS); } const now = resourceMonitorNow(); if (now - resourceMonitorState.lastPruneAt >= 1000) { resourceMonitorState.lastPruneAt = now; resourceMonitorPrune(now); } }; const resourceMonitorVisibilityAt = (at) => { for (let index = resourceMonitorState.visibilityTimeline.length - 1; index >= 0; index--) { const entry = resourceMonitorState.visibilityTimeline[index]; if (Number(entry.at) > at) continue; return ['visible', 'hidden'].includes(entry.state) ? entry.state : 'unknown'; } return 'unknown'; }; const resourceMonitorPushTimedEvent = (at, event) => resourceMonitorPushEvent(Object.assign({ at, visibility: resourceMonitorVisibilityAt(at), }, event)); const recordResourceMonitorVisibility = (reason = 'visibilitychange', at = resourceMonitorNow()) => { if (!resourceMonitorState.collecting) return; const state = document.visibilityState === 'visible' ? 'visible' : 'hidden'; const previous = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1]; if (previous && previous.state === state) return; resourceMonitorState.visibilityTimeline.push({ at, state }); resourceMonitorPushEvent({ at, visibility: state, scope: 'shared', kind: 'visibility', detail: reason === 'start' ? `开始取证 · 当前${state === 'visible' ? '前台' : '后台'}` : `页面切换为${state === 'visible' ? '前台' : '后台'}`, basis: 'Page Visibility', }); }; const resourceMonitorRequestEvents = (at = resourceMonitorNow()) => { const cutoff = at - RESOURCE_MONITOR_RETENTION_MS; pruneRequestFlowEvents(at); return REQUEST_FLOW_EVENTS.filter((event) => !event.controlOnly && Number(event.startedAt) >= cutoff && Number(event.startedAt) <= at).map((event) => { const visibility = resourceMonitorVisibilityAt(Number(event.startedAt)); const scope = event.source === 'reader' ? 'reader' : event.source === 'host' ? 'host' : 'shared'; const duration = Math.max(0, event.pending ? at - event.startedAt : Number(event.duration) || 0); const bytes = Math.max(0, Number(event.size) || 0); const status = event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成'; const initiator = event.resourceTimed ? String(event.transport || 'resource') : REQUEST_FLOW_TYPE_META[event.type]?.label || event.type; const request = event.resourceTimed ? event.path : `${event.method} ${event.path}`; const basis = event.resourceTimed ? `PerformanceResourceTiming${event.source === 'reader' ? ' + reader 元数据' : event.source === 'host' ? ' + 未标记请求' : ''}` : event.source === 'reader' ? '显式 reader 元数据' : event.source === 'host' ? '未标记 fetch/XHR' : 'Resource Timing'; return { id: `request-${event.id}`, at: Number(event.startedAt), visibility, scope, kind: 'resource', duration, bytes, detail: `${initiator} · ${request} · ${status} · ${resourceMonitorDuration(duration)}${bytes ? ` · ${formatRequestFlowBytes(bytes)}` : event.resourceTimed ? ' · 传输量受限或缓存命中' : ''}`, basis, }; }); }; const resourceMonitorResourceDropEvents = (at = resourceMonitorNow()) => REQUEST_FLOW_RESOURCE_DROPS .filter((entry) => entry.at >= at - RESOURCE_MONITOR_RETENTION_MS && entry.at <= at) .map((entry, index) => ({ id: `resource-drop-${entry.at}-${index}`, at: entry.at, visibility: resourceMonitorVisibilityAt(entry.at), scope: 'shared', kind: 'dropped', detail: `Resource Timing 有 ${entry.count} 条浏览器记录被丢弃;该区间统计不完整`, basis: 'PerformanceObserver droppedEntriesCount', })); const resourceMonitorIsReaderRoot = (node) => { if (!node || node.nodeType !== 1) return false; if (node === READER_PORTAL_HOST) return true; return !!node.closest('.ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback'); }; const resourceMonitorElementCount = (nodes, excludeReaderRoots = false) => Array.from(nodes || []).reduce((sum, node) => { if (!node || node.nodeType !== 1 || (excludeReaderRoots && resourceMonitorIsReaderRoot(node))) return sum; return sum + 1 + (typeof node.getElementsByTagName === 'function' ? node.getElementsByTagName('*').length : 0); }, 0); const resourceMonitorHostDomCount = () => { const total = document.getElementsByTagName('*').length; const readerRoots = document.querySelectorAll('html > .ldp-reader-portal-host,body > .ldp-overlay,body > .ldp-native-boost-menu,body > .ldp-lightbox,body > .ldp-user-card-fallback'); return Math.max(0, total - resourceMonitorElementCount(readerRoots)); }; const resourceMonitorRecordMutations = (scope, records) => { let added = 0; let removed = 0; records.forEach((record) => { added += resourceMonitorElementCount(record.addedNodes, scope === 'host'); removed += resourceMonitorElementCount(record.removedNodes, scope === 'host'); }); if (!added && !removed) return; const at = resourceMonitorNow(); resourceMonitorPushTimedEvent(at, { scope, kind: 'dom', added, removed, detail: `DOM +${added} / −${removed} 元素 · ${records.length} 个变更记录`, basis: 'MutationObserver 回调', }); }; const resourceMonitorRecordDroppedEntries = (entryType, options) => { const dropped = Math.max(0, Number(options && options.droppedEntriesCount) || 0); if (!dropped) return; const at = resourceMonitorNow(); resourceMonitorPushTimedEvent(at, { scope: 'shared', kind: 'dropped', detail: `${entryType} 有 ${dropped} 条浏览器记录被丢弃;该区间统计不完整`, basis: 'PerformanceObserver droppedEntriesCount', }); }; const resourceMonitorScriptScope = (script) => { const rawSource = String(script && script.sourceURL || ''); let source = rawSource; try { source = decodeURIComponent(rawSource); } catch (error) {} if (/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(source)) return 'reader'; try { const url = new URL(rawSource, location.href); if ((url.origin === BASE || /(?:^|\.)linux\.do$/i.test(url.hostname) || /(?:^|\.)ldstatic\.com$/i.test(url.hostname)) && /\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(url.pathname)) return 'host'; } catch (error) {} return 'shared'; }; const resourceMonitorScriptLabel = (script) => { const functionName = String(script && script.sourceFunctionName || '').trim(); if (functionName) return functionName.slice(0, 80); const source = String(script && script.sourceURL || ''); if (!source) return '匿名脚本'; try { const url = new URL(source, location.href); return `${url.host}${url.pathname}`.slice(0, 100); } catch (error) { return source.split(/[?#]/, 1)[0].slice(0, 100); } }; const requestResourceMonitorMemoryMeasurement = (at) => { const memory = resourceMonitorState.memory; const measure = window.performance && window.performance.measureUserAgentSpecificMemory; if (typeof measure !== 'function') { memory.status = 'unsupported'; return; } if (window.crossOriginIsolated !== true) { memory.status = 'not-isolated'; return; } if (memory.pending || at < Number(memory.nextAt || 0)) return; memory.pending = true; memory.status = 'measuring'; memory.nextAt = at + 30000 + Math.random() * 30000; let measurement; try { measurement = measure.call(window.performance); } catch (error) { measurement = Promise.reject(error); } Promise.resolve(measurement).then((result) => { const bytes = Number(result && result.bytes); const measuredAt = resourceMonitorNow(); memory.pending = false; if (!Number.isFinite(bytes)) { memory.status = 'error'; return; } memory.bytes = bytes; memory.status = 'measured'; resourceMonitorState.memorySamples.push({ at: measuredAt, heapUsed: bytes }); resourceMonitorPushTimedEvent(measuredAt, { scope: 'shared', kind: 'memory', detail: `页面内存估计 ${formatRequestFlowBytes(bytes)}`, basis: 'measureUserAgentSpecificMemory', }); }).catch(() => { memory.pending = false; memory.status = 'error'; }); }; const resourceMonitorStats = (samples, key) => { const values = samples.map((sample) => sample[key]).filter(Number.isFinite); if (!values.length) return { count: 0, average: 0, peak: 0, delta: 0 }; return { count: values.length, average: values.reduce((sum, value) => sum + value, 0) / values.length, peak: Math.max(...values), delta: values[values.length - 1] - values[0], }; }; const resourceMonitorSignedBytes = (value) => { const numeric = Number(value) || 0; if (!numeric) return '0 B'; return `${numeric > 0 ? '+' : '-'}${formatRequestFlowBytes(Math.abs(numeric))}`; }; const resourceMonitorSignedCount = (value) => { const numeric = Math.round(Number(value) || 0); return numeric > 0 ? `+${numeric}` : String(numeric); }; const resourceMonitorTrendLabel = (stats, formatter) => { if (stats.count < 2) return '→ —'; const arrow = stats.delta > 0 ? '↑' : stats.delta < 0 ? '↓' : '→'; return `${arrow} ${formatter(stats.delta)}`; }; const resourceMonitorChartPoints = (samples, key) => { const entries = samples.filter((sample) => Number.isFinite(sample[key])); if (!entries.length) return ''; if (entries.length === 1) return '0,17 240,17'; const stride = Math.max(1, Math.ceil(entries.length / 120)); const plotted = entries.filter((_entry, index) => index % stride === 0 || index === entries.length - 1); const values = plotted.map((entry) => entry[key]); const minimum = Math.min(...values); const maximum = Math.max(...values); const range = maximum - minimum || 1; return plotted.map((entry, index) => { const x = index / Math.max(1, plotted.length - 1) * 240; const y = 31 - (entry[key] - minimum) / range * 28; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); }; const resourceMonitorMemoryLabel = () => { const memory = resourceMonitorState.memory; if (memory.status === 'measured' && Number.isFinite(memory.bytes)) return formatRequestFlowBytes(memory.bytes); if (memory.status === 'measuring') return '正在测量'; if (memory.status === 'not-isolated') return '不可用(未隔离)'; if (memory.status === 'unsupported') return '浏览器未提供'; if (memory.status === 'error') return '测量失败'; return '等待测量'; }; const resourceMonitorScopeSnapshot = (at) => { const createCell = () => ({ requests: 0, requestDuration: 0, bytes: 0, scripts: 0, longTasks: 0, longTaskDuration: 0, domChanges: 0 }); const scopes = { reader: { visible: createCell(), hidden: createCell() }, host: { visible: createCell(), hidden: createCell() }, shared: { visible: createCell(), hidden: createCell() }, }; const cutoff = at - 60000; resourceMonitorRequestEvents(at).forEach((event) => { if (event.at < cutoff || !['visible', 'hidden'].includes(event.visibility)) return; const cell = scopes[event.scope][event.visibility]; cell.requests++; cell.requestDuration += event.duration; cell.bytes += event.bytes; }); resourceMonitorState.events.forEach((event) => { if (event.at < cutoff || event.at > at || !['visible', 'hidden'].includes(event.visibility)) return; const scope = Object.hasOwn(scopes, event.scope) ? event.scope : 'shared'; const cell = scopes[scope][event.visibility]; if (event.kind === 'script') cell.scripts += Math.max(0, Number(event.duration) || 0); if (event.kind === 'longtask') { cell.longTasks++; cell.longTaskDuration += Math.max(0, Number(event.duration) || 0); } if (event.kind === 'dom') cell.domChanges += Math.max(0, Number(event.added) || 0) + Math.max(0, Number(event.removed) || 0); }); return scopes; }; const resourceMonitorScopeCellLabel = (cell, scope) => { const parts = []; if (cell.requests) { const requestCopy = `请求 ${cell.requests} / ${resourceMonitorDuration(cell.requestDuration)}`; parts.push(cell.bytes ? `${requestCopy} / ${formatRequestFlowBytes(cell.bytes)}` : requestCopy); } if (cell.scripts) parts.push(`已归因脚本 ${resourceMonitorDuration(cell.scripts)}`); if (scope === 'shared' && cell.longTasks) parts.push(`长任务 ${cell.longTasks} / ${resourceMonitorDuration(cell.longTaskDuration)}`); if (cell.domChanges) parts.push(`DOM 变更 ${cell.domChanges}`); return parts.length ? parts.join(' · ') : '—'; }; const syncResourceMonitorScopeControls = (sample, at) => { const scopes = resourceMonitorScopeSnapshot(at); const scopeValues = { reader: `${sample.dom} DOM · ${sample.retainedFloors} 楼`, host: `${sample.hostDom} DOM`, shared: resourceMonitorMemoryLabel(), readerBasis: `reader 元数据 · Resource Timing · Reader DOM${resourceMonitorLongFramesSupported ? ' · LoAF 已确认脚本' : ''}`, hostBasis: `未标记 fetch/XHR · Resource Timing · Document DOM${resourceMonitorLongFramesSupported ? ' · 同源 LoAF 脚本' : ''}`, sharedBasis: `Long Tasks · Resource Timing · Page Visibility${resourceMonitorLongFramesSupported ? ' · 未归因 LoAF' : ''}`, }; resourceMonitorScopeRows.forEach((row) => { const scope = row.dataset.resourceMonitorScope; row.querySelector('[data-resource-monitor-scope-current]').textContent = scopeValues[scope] || '—'; row.querySelector('[data-resource-monitor-scope-visible]').textContent = resourceMonitorScopeCellLabel(scopes[scope].visible, scope); row.querySelector('[data-resource-monitor-scope-hidden]').textContent = resourceMonitorScopeCellLabel(scopes[scope].hidden, scope); row.querySelector('[data-resource-monitor-scope-basis]').textContent = scopeValues[`${scope}Basis`] || '—'; }); const recentEvents = resourceMonitorState.events.concat( resourceMonitorRequestEvents(at).filter((event) => ['visible', 'hidden'].includes(event.visibility)), resourceMonitorResourceDropEvents(at).filter((event) => ['visible', 'hidden'].includes(event.visibility)) ).filter((event) => event.at >= at - 60000); const visibleCount = recentEvents.filter((event) => event.visibility === 'visible').length; const hiddenCount = recentEvents.filter((event) => event.visibility === 'hidden').length; const unknownCount = recentEvents.length - visibleCount - hiddenCount; resourceMonitorEvidenceWindow.textContent = `${document.visibilityState === 'visible' ? '当前前台' : '当前后台'} · 前 ${visibleCount} / 后 ${hiddenCount}${unknownCount ? ` / 未归档 ${unknownCount}` : ''} 个原始事件`; }; const resourceMonitorScopeLabel = (scope) => RESOURCE_MONITOR_SCOPE_LABELS[scope] || '未归因'; const syncResourceMonitorEventLog = (at) => { if (!resourceMonitorEventLog) return; const events = resourceMonitorState.events.concat( resourceMonitorRequestEvents(at).filter((event) => ['visible', 'hidden'].includes(event.visibility)), resourceMonitorResourceDropEvents(at).filter((event) => ['visible', 'hidden'].includes(event.visibility)) ) .filter((event) => event.at >= at - RESOURCE_MONITOR_RETENTION_MS && event.at <= at) .sort((left, right) => right.at - left.at || String(right.id).localeCompare(String(left.id))) .slice(0, 48); resourceMonitorEventLog.innerHTML = events.length ? events.map((event) => { const visibilityLabel = event.visibility === 'visible' ? '前台' : event.visibility === 'hidden' ? '后台' : '未知'; return `<div class="ldp-resource-monitor-event-row" role="listitem"> <time datetime="${escAttr(new Date(event.at).toISOString())}">${esc(resourceMonitorTimestamp(event.at))}</time> <span class="ldp-resource-monitor-event-state" data-visibility="${escAttr(event.visibility)}">${visibilityLabel}</span> <span class="ldp-resource-monitor-event-scope">${esc(resourceMonitorScopeLabel(event.scope))}</span> <span class="ldp-resource-monitor-event-detail">${esc(event.detail)}</span> <span class="ldp-resource-monitor-event-basis">${esc(event.basis)}</span> </div>`; }).join('') : '<div class="ldp-resource-monitor-event-empty">等待 Performance Timeline、Resource Timing、DOM 或可见性事件。</div>'; }; const resourceMonitorHealthInfo = (samples, current, at) => { const baselineSamples = samples.filter((sample) => sample.at >= at - 12000); if (baselineSamples.length < 10) { return { level: 'normal', state: '建立基线', detail: `最近 12 秒取得 ${baselineSamples.length} 个真实快照;满 10 个后开始判断 DOM 增长、共享长任务和请求排队。`, }; } const recent = samples.filter((sample) => sample.at >= at - 60000); const first = recent[0] || current; const domGrowth = current.dom - first.dom; const retainedFloorGrowth = current.retainedFloors - first.retainedFloors; const warnings = []; let level = 'normal'; if (current.longTaskDuration >= 2000) { level = 'danger'; warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`); } else if (current.longTaskDuration >= 500) { level = 'warning'; warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`); } if (current.queuedRequests >= 10) { level = 'danger'; warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`); } else if (current.queuedRequests >= 3) { if (level === 'normal') level = 'warning'; warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`); } if (domGrowth >= 1000 && retainedFloorGrowth >= 50) { if (level === 'normal') level = 'warning'; warnings.push(`近一分钟 DOM 增加 ${domGrowth} 个,保留楼层增加 ${retainedFloorGrowth} 个`); } if (warnings.length) { return { level, state: level === 'danger' ? '资源压力高' : '需要关注', detail: `${warnings.join(';')}。继续观察趋势,回落后状态会自动恢复。`, }; } return { level: 'normal', state: '采样正常', detail: '最近一分钟未发现阅读器 DOM 快速膨胀、页面共享长任务或阅读器请求积压;内存估计不参与自动判定。', }; }; const syncResourceMonitorControls = () => { if (!resourceMonitorSection || !resourceMonitorMetrics.length) return; const at = resourceMonitorNow(); const activeContext = readerShell.activeContext; requestResourceMonitorMemoryMeasurement(at); resourceMonitorPrune(at); const recentLongTasks = resourceMonitorState.events.filter((entry) => entry.kind === 'longtask' && entry.at >= at - 10000); const longTaskDuration = recentLongTasks.reduce((sum, entry) => sum + entry.duration, 0); let schedulerState = null; try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch (error) {} const recentReaderRequests = resourceMonitorRequestEvents(at) .filter((event) => event.scope === 'reader' && event.at >= at - 60000); const readerTransfer = recentReaderRequests.reduce((sum, event) => sum + event.bytes, 0); const sample = { at, visibility: document.visibilityState === 'visible' ? 'visible' : 'hidden', longTaskCount: resourceMonitorState.longTasksAvailable ? recentLongTasks.length : null, longTaskDuration: resourceMonitorState.longTasksAvailable ? longTaskDuration : null, dom: overlay.getElementsByTagName('*').length + 1, hostDom: resourceMonitorHostDomCount(), mountedFloors: activeContext?.streamMountedNodes?.size || 0, retainedFloors: activeContext?.streamNodeMap?.size || 0, nested: activeContext?.subReplyState?.size || 0, media: body.querySelectorAll('img,video,audio,iframe').length, activeRequests: schedulerState?.active || 0, queuedRequests: schedulerState?.queued || 0, readerRequestCount: recentReaderRequests.length, readerTransfer, }; const lastSample = resourceMonitorState.samples[resourceMonitorState.samples.length - 1]; if (!lastSample || at - lastSample.at >= 800) { if (lastSample && at - lastSample.at > 1800) { const crossedHidden = lastSample.visibility === 'hidden' || resourceMonitorState.visibilityTimeline.some((entry) => entry.at > lastSample.at && entry.at <= at && entry.state === 'hidden'); const crossedStopped = resourceMonitorState.visibilityTimeline.some((entry) => entry.at > lastSample.at && entry.at <= at && entry.state === 'stopped'); resourceMonitorPushEvent({ at, visibility: crossedStopped ? 'unknown' : crossedHidden ? 'hidden' : resourceMonitorVisibilityAt(at), scope: 'shared', kind: 'gap', duration: at - lastSample.at, detail: `快照空档 ${resourceMonitorDuration(at - lastSample.at)}${crossedStopped ? '(包含取证暂停)' : crossedHidden ? '(包含后台节流或冻结)' : ''};未补造中间样本`, basis: 'High Resolution Time', }); } resourceMonitorState.samples.push(sample); } resourceMonitorPrune(at); if (document.visibilityState !== 'visible') return; const samples = resourceMonitorState.samples; const stats = RESOURCE_MONITOR_STAT_KEYS.reduce((result, key) => { result[key] = resourceMonitorStats(key === 'heapUsed' ? resourceMonitorState.memorySamples : samples, key); return result; }, {}); const metricValues = { heap: resourceMonitorMemoryLabel(), longTasks: resourceMonitorState.longTasksAvailable ? `${recentLongTasks.length} 次 · ${resourceMonitorDuration(longTaskDuration)}` : '浏览器未提供', dom: `${sample.dom} 个`, floors: `${sample.mountedFloors} / ${sample.retainedFloors} 个`, nested: `${sample.nested} 组`, media: `${sample.media} 个`, requests: schedulerState ? `${schedulerState.active} / ${schedulerState.queued} 条` : '—', network: `${recentReaderRequests.length} 次 · ${formatRequestFlowBytes(readerTransfer)}`, heapAverage: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.average) : '—', longTasksAverage: stats.longTaskDuration.count ? `${stats.longTaskCount.average.toFixed(1)} / ${Math.round(stats.longTaskDuration.average)}ms` : '—', domAverage: `${Math.round(stats.dom.average)} 个`, floorsAverage: `${Math.round(stats.mountedFloors.average)} / ${Math.round(stats.retainedFloors.average)}`, nestedAverage: `${Math.round(stats.nested.average)} 组`, mediaAverage: `${Math.round(stats.media.average)} 个`, requestsAverage: `${stats.activeRequests.average.toFixed(1)} / ${stats.queuedRequests.average.toFixed(1)}`, networkAverage: `${Math.round(stats.readerRequestCount.average)} / ${formatRequestFlowBytes(stats.readerTransfer.average)}`, heapPeak: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.peak) : '—', longTasksPeak: stats.longTaskDuration.count ? `${Math.round(stats.longTaskCount.peak)} / ${Math.round(stats.longTaskDuration.peak)}ms` : '—', domPeak: `${Math.round(stats.dom.peak)} 个`, floorsPeak: `${Math.round(stats.mountedFloors.peak)} / ${Math.round(stats.retainedFloors.peak)}`, nestedPeak: `${Math.round(stats.nested.peak)} 组`, mediaPeak: `${Math.round(stats.media.peak)} 个`, requestsPeak: `${Math.round(stats.activeRequests.peak)} / ${Math.round(stats.queuedRequests.peak)}`, networkPeak: `${Math.round(stats.readerRequestCount.peak)} / ${formatRequestFlowBytes(stats.readerTransfer.peak)}`, heapTrend: resourceMonitorTrendLabel(stats.heapUsed, resourceMonitorSignedBytes), longTasksTrend: resourceMonitorTrendLabel(stats.longTaskDuration, (value) => `${resourceMonitorSignedCount(value)}ms`), domTrend: resourceMonitorTrendLabel(stats.dom, resourceMonitorSignedCount), floorsTrend: resourceMonitorTrendLabel(stats.retainedFloors, resourceMonitorSignedCount), nestedTrend: resourceMonitorTrendLabel(stats.nested, resourceMonitorSignedCount), mediaTrend: resourceMonitorTrendLabel(stats.media, resourceMonitorSignedCount), requestsTrend: resourceMonitorTrendLabel(stats.queuedRequests, resourceMonitorSignedCount), networkTrend: resourceMonitorTrendLabel(stats.readerTransfer, resourceMonitorSignedBytes), }; resourceMonitorValueColumns.forEach(([elements, dataKey, suffix]) => elements.forEach((element) => { element.textContent = metricValues[`${element.dataset[dataKey]}${suffix}`] || '—'; })); const chartCurrentValues = { heapUsed: metricValues.heap, dom: metricValues.dom, retainedFloors: `${sample.retainedFloors} 个`, }; resourceMonitorChartRows.forEach((row) => { const key = row.dataset.resourceMonitorChart; const chartSamples = key === 'heapUsed' ? resourceMonitorState.memorySamples : samples; row.querySelector('polyline').setAttribute('points', resourceMonitorChartPoints(chartSamples, key)); row.querySelector('[data-resource-monitor-chart-current]').textContent = chartCurrentValues[key] || '—'; }); const coveredMs = samples.length > 1 ? samples[samples.length - 1].at - samples[0].at : 0; const maximumGap = samples.slice(1).reduce((maximum, entry, index) => Math.max(maximum, entry.at - samples[index].at), 0); const visibleSamples = samples.filter((entry) => entry.visibility === 'visible').length; const hiddenSamples = samples.filter((entry) => entry.visibility === 'hidden').length; const coveredLabel = coveredMs >= 60000 ? `${(coveredMs / 60000).toFixed(1)} 分钟` : `${Math.round(coveredMs / 1000)} 秒`; resourceMonitorTrendWindow.textContent = `${coveredLabel} · 前 ${visibleSamples} / 后 ${hiddenSamples}${maximumGap > 1800 ? ` · 最大空档 ${formatRequestFlowDuration(maximumGap)}` : ''}`; syncResourceMonitorScopeControls(sample, at); syncResourceMonitorEventLog(at); const health = resourceMonitorHealthInfo(samples, sample, at); resourceMonitorHealth.dataset.level = health.level; resourceMonitorHealthState.textContent = health.state; resourceMonitorHealthDetail.textContent = health.detail; resourceMonitorUpdated.textContent = `最近快照 ${resourceMonitorTimestamp(at)} · ${sample.visibility === 'visible' ? '前台' : '后台'} · 仅内存保留`; }; const stopResourceMonitorUpdates = () => { if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer); resourceMonitorState.timer = 0; disconnectResourceMonitorObservers(); if (resourceMonitorState.collecting) { const at = resourceMonitorNow(); resourceMonitorPushTimedEvent(at, { scope: 'shared', kind: 'capture', detail: '资源取证暂停', basis: '监控面板生命周期', }); resourceMonitorState.visibilityTimeline.push({ at, state: 'stopped' }); } resourceMonitorState.collecting = false; }; const observeResourceMonitorPerformance = (supported, key, type, callback) => { if (!supported) return false; try { resourceMonitorState[key] = new window.PerformanceObserver(callback); resourceMonitorState[key].observe({ type }); return true; } catch (error) { resourceMonitorState[key] = null; return false; } }; const startResourceMonitorUpdates = () => { if (resourceMonitorState.collecting) { syncResourceMonitorControls(); return; } if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer); resourceMonitorState.timer = 0; resourceMonitorState.collecting = true; resourceMonitorState.longTasksAvailable = false; recordResourceMonitorVisibility('start'); resourceMonitorState.longTasksAvailable = observeResourceMonitorPerformance( resourceMonitorLongTasksSupported, 'longTaskObserver', 'longtask', (list, ...callbackArgs) => { resourceMonitorRecordDroppedEntries('Long Tasks', callbackArgs[1]); list.getEntries().forEach((entry) => { const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0); const duration = Math.max(0, Number(entry.duration) || 0); resourceMonitorPushTimedEvent(startedAt, { scope: 'shared', kind: 'longtask', duration, detail: `页面共享主线程长任务 ${resourceMonitorDuration(duration)}`, basis: 'PerformanceLongTaskTiming · 1ms', }); }); }); observeResourceMonitorPerformance( resourceMonitorLongFramesSupported, 'longFrameObserver', 'long-animation-frame', (list, ...callbackArgs) => { resourceMonitorRecordDroppedEntries('Long Animation Frames', callbackArgs[1]); list.getEntries().forEach((entry) => { const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0); const frameDuration = Math.max(0, Number(entry.duration) || 0); const blockingDuration = Math.max(0, Number(entry.blockingDuration) || 0); resourceMonitorPushTimedEvent(startedAt, { scope: 'shared', kind: 'frame', duration: frameDuration, detail: `长动画帧 ${resourceMonitorDuration(frameDuration)} · 阻塞 ${resourceMonitorDuration(blockingDuration)}`, basis: 'Long Animation Frames', }); const groups = new Map(); Array.from(entry.scripts || []).forEach((script) => { const duration = Math.max(0, Number(script.duration) || 0); if (!duration) return; const scope = resourceMonitorScriptScope(script); const group = groups.get(scope) || { duration: 0, forced: 0, count: 0, labels: [] }; group.duration += duration; group.forced += Math.max(0, Number(script.forcedStyleAndLayoutDuration) || 0); group.count++; if (group.labels.length < 2) group.labels.push(resourceMonitorScriptLabel(script)); groups.set(scope, group); }); groups.forEach((group, scope) => { resourceMonitorPushTimedEvent(startedAt, { scope, kind: 'script', duration: group.duration, detail: `长帧内已归因脚本 ${resourceMonitorDuration(group.duration)}${group.forced ? ` · 强制布局 ${resourceMonitorDuration(group.forced)}` : ''} · ${group.labels.join(' / ')}`, basis: `PerformanceScriptTiming · ${group.count} 段`, }); }); }); }); if (typeof window.MutationObserver === 'function') { try { resourceMonitorState.mutationObserver = new window.MutationObserver((records) => { resourceMonitorRecordMutations('reader', records.filter((record) => body.contains(record.target))); resourceMonitorRecordMutations('host', records); }); resourceMonitorState.mutationObserver.observe(document.documentElement, { childList: true, subtree: true }); } catch (error) { disconnectResourceMonitorObservers(['mutationObserver']); } } syncResourceMonitorControls(); resourceMonitorState.timer = setInterval(syncResourceMonitorControls, 1000); }; const syncJumpHighlightControls = () => { const config = jumpHighlightDraft; JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => { const control = jumpHighlightControls[field.key]; control.input.value = String(config[field.key]); control.output.textContent = field.format(config[field.key]); if (field.type !== 'color') syncRangeVisual(control.input); }); const changed = jumpHighlightDraftChanged(); jumpHighlightStatus.textContent = changed ? '闪烁效果正在实时预览;点击“确认应用”后保存。' : '闪烁效果当前配置已应用。'; jumpHighlightStatus.classList.toggle('balanced', !changed); jumpHighlightApplyBtn.disabled = !changed; applyJumpHighlightSettings(config); syncSettingsDraftIndicators(); }; let loadingAnimationPreviewRandomKey = ''; const syncLoadingAnimationControls = (reroll = false) => { const preference = normalizeReaderLoadingAnimation(PREFS.loadingAnimation); if (preference === 'random') { if (reroll || !loadingAnimationPreviewRandomKey) { const candidates = READER_LOADING_ANIMATIONS.filter((animation) => animation.key !== loadingAnimationPreviewRandomKey); const next = candidates[Math.floor(Math.random() * candidates.length)] || READER_LOADING_ANIMATIONS[0]; loadingAnimationPreviewRandomKey = next.key; } } else { loadingAnimationPreviewRandomKey = ''; } const animation = resolveReaderLoadingAnimation(preference, loadingAnimationPreviewRandomKey); loadingAnimationSelect.value = preference; loadingAnimationPreview.replaceChildren(); loadingAnimationPreview.insertAdjacentHTML('afterbegin', readerLoadingVisualHTML(animation)); const animationNumber = READER_LOADING_ANIMATIONS.findIndex((candidate) => candidate.key === animation.key) + 1; loadingAnimationPreviewLabel.textContent = `${String(animationNumber).padStart(2, '0')} / ${READER_LOADING_ANIMATIONS.length} · ${animation.label}${preference === 'random' ? ' · 随机预览' : ' · 固定使用'}`; loadingAnimationReroll.hidden = preference !== 'random'; }; const boostCopyDraftFromControls = () => ({ boostCopyMode: boostCopyModeSelect.value, boostCopyPrefix: boostCopyPrefixInput.value, boostCopyCounterMarker: boostCopyCounterMarkerInput.value, boostCopyCounterStep: boostCopyCounterStepInput.value, boostCopyFixedSuffix: boostCopyFixedSuffixInput.value, }); const syncBoostCopyPreview = (prefs = boostCopyDraftFromControls()) => { const firstResult = applyBoostCopyRule('原 Boost', prefs); const config = boostCopyConfigFromPrefs(prefs); boostCopyPreview.textContent = config.mode === 'counter' ? `${firstResult} → ${applyBoostCopyRule(firstResult, prefs)}` : firstResult; }; const syncOtherControls = () => { const historyButtonsAlwaysVisible = PREFS.historyButtonsAlwaysVisible === true; const historyEdgeTriggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent); historyButtonsAlwaysVisibleInput.checked = historyButtonsAlwaysVisible; historyEdgeTriggerRange.value = String(historyEdgeTriggerPercent); historyEdgeTriggerRange.disabled = historyButtonsAlwaysVisible; historyEdgeTriggerValue.value = `${historyEdgeTriggerPercent}%`; historyEdgeTriggerValue.textContent = `${historyEdgeTriggerPercent}%`; syncRangeVisual(historyEdgeTriggerRange); overlay._ldpSyncHistoryButtonVisibilityPreference?.(); historySortModeSelect.value = PREFS.historySortMode; openTopicsAtFirstPostInput.checked = PREFS.openTopicsAtFirstPost === true; expandNestedRepliesByDefaultInput.checked = PREFS.expandNestedRepliesByDefault === true; expandLeafNestedRepliesInput.checked = PREFS.expandLeafNestedReplies === true; nestedReplyDisplayWarning.hidden = !(PREFS.expandNestedRepliesByDefault && PREFS.expandLeafNestedReplies); const boostCopy = boostCopyConfigFromPrefs(PREFS); boostCopyModeSelect.value = boostCopy.mode; boostCopyPrefixInput.value = boostCopy.prefix; boostCopyCounterMarkerInput.value = boostCopy.counterMarker; boostCopyCounterStepInput.value = String(boostCopy.counterStep); boostCopyFixedSuffixInput.value = boostCopy.fixedSuffix; const counterMode = boostCopy.mode === 'counter'; boostCopyCounterRows.forEach((row) => { row.hidden = !counterMode; }); boostCopyTextRows.forEach((row) => { row.hidden = counterMode; }); syncBoostCopyPreview(PREFS); }; let cacheControlsSyncToken = 0; const syncCacheControls = async () => { const token = ++cacheControlsSyncToken; flushPersistentCacheWrites(); await flushTopicSnapshotWrites(); const responseStatsRequest = storedResponseCacheStats(); const stats = await Promise.all(Array.from(cacheSizeEls).map(async (el) => ({ el, stat: await cacheTypeStats(el.dataset.cacheSize, responseStatsRequest), }))); if (token !== cacheControlsSyncToken) return; stats.forEach(({ el, stat }) => { const key = el.dataset.cacheSize; el.textContent = formatCacheTypeStats(key, stat); }); cacheRetentionEls.forEach((el) => { el.textContent = cacheRetentionLabel(el.dataset.cacheRetention); }); cacheClearBtn.disabled = !Array.from(cacheSelects).some((input) => input.checked); }; settingHelpTooltip.id = 'ldp-setting-help-tooltip'; let activeSettingHelpTarget = null; const hideSettingHelp = () => { if (activeSettingHelpTarget && activeSettingHelpTarget.getAttribute('aria-describedby') === settingHelpTooltip.id) { activeSettingHelpTarget.removeAttribute('aria-describedby'); } activeSettingHelpTarget = null; settingHelpTooltip.classList.remove('is-visible'); settingHelpTooltip.hidden = true; }; const showSettingHelp = (target) => { const copy = target && target.dataset.settingHelp; if (!copy) return; if (activeSettingHelpTarget && activeSettingHelpTarget !== target) hideSettingHelp(); activeSettingHelpTarget = target; target.setAttribute('aria-describedby', settingHelpTooltip.id); settingHelpTooltip.textContent = copy; settingHelpTooltip.hidden = false; settingHelpTooltip.classList.remove('is-visible'); const targetRect = target.getBoundingClientRect(); const tooltipRect = settingHelpTooltip.getBoundingClientRect(); const margin = 12; const gap = 8; const maximumLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin); const left = Math.min(maximumLeft, Math.max( margin, targetRect.left + (targetRect.width - tooltipRect.width) / 2 )); let top = targetRect.top - tooltipRect.height - gap; if (top < margin) top = targetRect.bottom + gap; top = Math.min(Math.max(margin, top), Math.max(margin, window.innerHeight - tooltipRect.height - margin)); settingHelpTooltip.style.left = `${Math.round(left)}px`; settingHelpTooltip.style.top = `${Math.round(top)}px`; settingHelpTooltip.classList.add('is-visible'); }; let userInfoLoadId = 0; let userInfoLastRefreshAt = 0; let userInfoLastRefreshTimer = 0; const stopUserInfoLastRefreshTicker = () => { if (userInfoLastRefreshTimer) clearInterval(userInfoLastRefreshTimer); userInfoLastRefreshTimer = 0; }; const renderUserInfoLastRefresh = () => { if (!userInfoLastRefresh) return; if (!userInfoLastRefreshAt) { userInfoLastRefresh.textContent = '最后刷新 · 暂无'; delete userInfoLastRefresh.dataset.ldpTooltipLabel; userInfoLastRefresh.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); return; } const elapsed = Math.max(0, Date.now() - userInfoLastRefreshAt); const seconds = Math.floor(elapsed / 1000); const relative = seconds < 5 ? '刚刚' : seconds < 60 ? `${seconds} 秒前` : seconds < 3600 ? `${Math.floor(seconds / 60)} 分钟前` : seconds < 86400 ? `${Math.floor(seconds / 3600)} 小时前` : `${Math.floor(seconds / 86400)} 天前`; const beijingTime = new Date(userInfoLastRefreshAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).replaceAll('/', '-').replace(',', ''); userInfoLastRefresh.textContent = `最后刷新 · ${relative}`; userInfoLastRefresh.dataset.ldpTooltipLabel = `北京时间 ${beijingTime}`; userInfoLastRefresh.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }; const syncUserInfoLastRefresh = (value) => { userInfoLastRefreshAt = Number(value || 0); renderUserInfoLastRefresh(); stopUserInfoLastRefreshTicker(); if (userInfoLastRefreshAt && userInfoContent?.closest('.ldp-settings-section')?.hidden === false) { userInfoLastRefreshTimer = setInterval(renderUserInfoLastRefresh, 1000); } }; const syncUserInfoTitleRefresh = (refreshing) => { if (!userInfoTitleRefresh) return; userInfoTitleRefresh.disabled = refreshing; userInfoTitleRefresh.classList.toggle('is-refreshing', refreshing); userInfoTitleRefresh.setAttribute('aria-label', refreshing ? '正在刷新用户信息' : '刷新用户信息'); }; const loadUserInfoSettings = async (force = false) => { if (!userInfoContent) return; const loadId = ++userInfoLoadId; if (force) syncUserInfoTitleRefresh(true); await ensureMe(force); if (loadId !== userInfoLoadId) return; const username = String(ME_USERNAME || ME_CURRENT_USER && ME_CURRENT_USER.username || '').trim(); if (!username) { userInfoContent.innerHTML = '<div class="ldp-user-info-error">当前未登录 LINUX DO,无法读取用户资料。</div>'; syncUserInfoTitleRefresh(false); return; } const cachedUser = getCachedUserCard(username); const cachedConnect = cachedUser && cachedUser._ldp_connect_trust || null; syncUserInfoLastRefresh(Math.max( Number(cachedUser && (cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at) || 0), Number(cachedConnect && cachedConnect.cachedAt || 0), )); if (cachedUser) { renderSettingsUserInfo( userInfoContent, Object.assign({}, cachedUser, ME_CURRENT_USER || {}, { username }), cachedConnect && cachedConnect.data, null, { refreshing: force, cachedAt: cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at } ); } else { userInfoContent.innerHTML = '<div class="ldp-user-info-loading">正在加载用户信息…</div>'; } const [profileResult, connectResult] = await Promise.allSettled([ fetchSettingsUserInfo(username, force), fetchConnectTrustData(force, cachedConnect), ]); if (loadId !== userInfoLoadId) return; const profile = profileResult.status === 'fulfilled' ? profileResult.value : cachedUser; const user = Object.assign({}, cachedUser || {}, ME_CURRENT_USER || {}, profile || {}, { username }); const connectData = connectResult.status === 'fulfilled' ? connectResult.value : cachedConnect && cachedConnect.data || null; const connectError = connectResult.status === 'rejected' ? connectResult.reason : null; if (connectResult.status === 'fulfilled') { user._ldp_connect_trust = { data: connectData, cachedAt: Number(CONNECT_TRUST_CACHE && CONNECT_TRUST_CACHE.cachedAt || Date.now()), }; } if (profileResult.status === 'fulfilled' || connectResult.status === 'fulfilled') { setCachedUserCard(username, user, 0); syncUserInfoLastRefresh(Math.max( Number(user._ldp_user_info_checked_at || user._ldp_summary_checked_at || 0), Number(user._ldp_connect_trust && user._ldp_connect_trust.cachedAt || 0), Date.now(), )); } renderSettingsUserInfo(userInfoContent, user, connectData, connectError, { cachedAt: user._ldp_user_info_checked_at || user._ldp_summary_checked_at, }); syncUserInfoTitleRefresh(false); }; const resetSettingsDrafts = (except = '') => { if (except !== 'font') { fontDraftProfiles = currentFontProfiles(); sharedFontDraft = currentSharedFontProfile(); fontSharingDraft = Object.assign({}, currentReaderProfileSharing().font); applyFontScales(); } if (except !== 'layout') { layoutDraftProfiles = currentLayoutProfiles(); sharedLayoutDraft = currentSharedLayoutProfile(); layoutSharingDraft = Object.assign({}, currentReaderProfileSharing().layout); layoutDraftEditedRegion = ''; applyLayoutRatios(); } if (except !== 'appearance') { appearanceDraftProfiles = currentAppearanceProfiles(); sharedAppearanceDraft = currentSharedAppearanceProfile(); appearanceSharingDraft = Object.assign({}, currentReaderProfileSharing().appearance); applyAppearanceColors(); } if (except !== 'flash') { jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); applyJumpHighlightSettings(); } syncSettingsDraftIndicators(); }; const selectSettingsProfile = { image(profile) { imageSettingsProfile = profile; syncImageScaleControls(); }, font(profile, fromTab) { if (fromTab) { closeFontFamilyMenu(); fontFamilyManualEditing.clear(); } fontSettingsProfile = profile; applyFontScales(); syncFontControls(); }, layout(profile) { layoutSettingsProfile = profile; layoutDraftEditedRegion = ''; applyLayoutRatios(); syncLayoutControls(); }, appearance(profile) { appearanceSettingsProfile = profile; applyAppearanceColors(); syncAppearanceControls(); }, }; const settingsPanelLifecycle = { user: [() => { settingsPanel.scrollTop = 0; void loadUserInfoSettings(false); }], image: [() => selectSettingsProfile.image(activeReaderProfileName())], font: [() => selectSettingsProfile.font(activeReaderProfileName())], layout: [() => selectSettingsProfile.layout(activeReaderProfileName())], appearance: [() => selectSettingsProfile.appearance(activeReaderProfileName())], window: [null, syncReaderWindowControls], flash: [null, () => { syncJumpHighlightControls(); syncLoadingAnimationControls(); }], performance: [null, syncPerformanceControls], other: [null, syncOtherControls], cache: [null, syncCacheControls], }; const showSettingsPanel = (name) => { closeFontFamilyMenu(); hideSettingHelp(); settingsRangeSaveReminderCategory = ''; settingsTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.settingsPanel === name)); settingsSections.forEach((section) => { section.hidden = section.dataset.settingsPanel !== name; }); if (name !== 'user') stopUserInfoLastRefreshTicker(); if (name === 'resource-monitor') startResourceMonitorUpdates(); else stopResourceMonitorUpdates(); if (name === 'request-flow') startRequestFlowUpdates(); else stopRequestFlowUpdates(); const [beforeRouting, afterRouting] = settingsPanelLifecycle[name] || []; if (beforeRouting) beforeRouting(); syncProfileRoutingNotes(); if (afterRouting) afterRouting(); syncSettingsDraftIndicators(); }; const syncSettingsNavScrollHints = () => { if (!settingsNav || !settingsNavScrollUp || !settingsNavScrollDown) return; const maxScroll = Math.max(0, settingsNav.scrollHeight - settingsNav.clientHeight); settingsNavScrollUp.hidden = maxScroll < 2 || settingsNav.scrollTop < 2; settingsNavScrollDown.hidden = maxScroll < 2 || settingsNav.scrollTop >= maxScroll - 2; }; const scrollSettingsNav = (direction) => { if (!settingsNav) return; const distance = Math.max(120, Math.round(settingsNav.clientHeight * .7)); const reduceMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; settingsNav.scrollBy({ top: direction * distance, behavior: reduceMotion ? 'auto' : 'smooth' }); }; let settingsRangeDragRow = null; let settingsColorPickRow = null; let settingsWindowDrag = null; let settingsWindowDragFrame = 0; let settingsRangeSaveReminderCategory = ''; const profileSettingsDraftChangeCount = ( fields, draftProfiles, sharedDraft, sharingDraft, savedProfiles, savedShared, savedSharing, valueFields = (field) => [field], differs = (left, right) => left !== right ) => fields.reduce((count, field) => { if (sharingDraft[field] !== savedSharing[field]) return count + 1; const keys = valueFields(field); if (sharingDraft[field]) { return count + (keys.some((key) => differs(sharedDraft[key], savedShared[key])) ? 1 : 0); } return count + READER_PROFILE_KEYS.reduce((profileCount, profile) => profileCount + ( keys.some((key) => differs(draftProfiles[profile][key], savedProfiles[profile][key])) ? 1 : 0 ), 0); }, 0); const fontDraftValueFields = (field) => { const scope = FONT_SCOPE_KEYS.find((name) => fontScopeConfig(name).family === field); return scope ? [field, fontScopeConfig(scope).customFamily] : [field]; }; const fontDraftChangeCount = () => profileSettingsDraftChangeCount( READER_PROFILE_SHARING_FIELDS.font, fontDraftProfiles, sharedFontDraft, fontSharingDraft, currentFontProfiles(), currentSharedFontProfile(), currentReaderProfileSharing().font, fontDraftValueFields ); const layoutDraftChangeCount = () => profileSettingsDraftChangeCount( LAYOUT_REGION_KEYS, layoutDraftProfiles, sharedLayoutDraft, layoutSharingDraft, currentLayoutProfiles(), currentSharedLayoutProfile(), currentReaderProfileSharing().layout, undefined, (left, right) => Math.abs(left - right) >= 0.01 ); const appearanceDraftChangeCount = () => profileSettingsDraftChangeCount( APPEARANCE_PROFILE_VALUE_KEYS, appearanceDraftProfiles, sharedAppearanceDraft, appearanceSharingDraft, currentAppearanceProfiles(), currentSharedAppearanceProfile(), currentReaderProfileSharing().appearance ); const jumpHighlightDraftChangeCount = () => { const saved = jumpHighlightConfigFromPrefs(PREFS); return JUMP_HIGHLIGHT_SETTING_FIELDS.reduce((count, field) => count + (jumpHighlightDraft[field.key] !== saved[field.key] ? 1 : 0), 0); }; const settingsDraftSummary = () => [ { panel: 'font', label: '字体设置', count: fontDraftChangeCount() }, { panel: 'layout', label: '布局设置', count: layoutDraftChangeCount() }, { panel: 'appearance', label: '外观设置', count: appearanceDraftChangeCount() }, { panel: 'flash', label: '闪烁动效', count: jumpHighlightDraftChangeCount() }, ].filter((entry) => entry.count > 0); function syncSettingsDraftIndicators() { const counts = Object.fromEntries(settingsDraftSummary().map((entry) => [entry.panel, entry.count])); settingsTabs.forEach((tab) => { const count = counts[tab.dataset.settingsPanel] || 0; const badge = tab.querySelector('.ldp-settings-tab-draft-count'); const label = SETTINGS_PANEL_META.find(([id]) => id === tab.dataset.settingsPanel)?.[2] || '设置'; tab.classList.toggle('has-draft', count > 0); tab.setAttribute('aria-label', count > 0 ? `${label},${count} 项未保存` : label); if (!badge) return; badge.hidden = count <= 0; badge.textContent = count > 99 ? '99+' : String(count); }); } const hasUnsavedFontDraft = () => fontDraftChangeCount() > 0; const hasUnsavedLayoutDraft = () => layoutDraftChangeCount() > 0; const hasUnsavedAppearanceDraft = () => appearanceDraftChangeCount() > 0; const hasUnsavedJumpHighlightDraft = () => jumpHighlightDraftChangeCount() > 0; const remindUnsavedRangeSetting = (range) => { const category = range.closest('.ldp-settings-section')?.dataset.settingsPanel || ''; const unsaved = category === 'font' ? hasUnsavedFontDraft() : category === 'layout' ? hasUnsavedLayoutDraft() : category === 'appearance' ? hasUnsavedAppearanceDraft() : category === 'flash' ? hasUnsavedJumpHighlightDraft() : false; if (!unsaved) { if (settingsRangeSaveReminderCategory === category) settingsRangeSaveReminderCategory = ''; return; } if (settingsRangeSaveReminderCategory === category) return; settingsRangeSaveReminderCategory = category; showSelectionToast('修改已实时预览,但还未保存;请点击本页底部的“确认应用”。'); }; const stopSettingsRangeDrag = () => { settingsPopover.classList.remove('ldp-range-dragging'); if (settingsRangeDragRow) settingsRangeDragRow.classList.remove('ldp-range-drag-active'); settingsRangeDragRow = null; }; const stopSettingsColorPick = () => { settingsPopover.classList.remove('ldp-color-picking'); if (settingsColorPickRow) settingsColorPickRow.classList.remove('ldp-color-pick-active'); settingsColorPickRow = null; }; const moveSettingsWindowTo = (left, top, width, height) => { const margin = 8; const maximumLeft = Math.max(margin, window.innerWidth - width - margin); const maximumTop = Math.max(margin, window.innerHeight - height - margin); const position = { left: Math.round(Math.min(maximumLeft, Math.max(margin, left))), top: Math.round(Math.min(maximumTop, Math.max(margin, top))), }; settingsPopover.style.left = `${position.left}px`; settingsPopover.style.top = `${position.top}px`; settingsPopover.style.transform = 'none'; return position; }; const keepSettingsWindowVisible = () => { if (settingsPopover.style.transform !== 'none') return; const rect = settingsPopover.getBoundingClientRect(); moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height); }; const renderSettingsWindowDrag = () => { settingsWindowDragFrame = 0; if (!settingsWindowDrag) return; const drag = settingsWindowDrag; const margin = 8; const maximumLeft = Math.max(margin, window.innerWidth - drag.width - margin); const maximumTop = Math.max(margin, window.innerHeight - drag.height - margin); drag.previewLeft = Math.min(maximumLeft, Math.max( margin, drag.startLeft + drag.clientX - drag.startX, )); drag.previewTop = Math.min(maximumTop, Math.max( margin, drag.startTop + drag.clientY - drag.startY, )); settingsPopover.style.transform = `translate3d(${drag.previewLeft - drag.startLeft}px,${drag.previewTop - drag.startTop}px,0)`; }; const stopSettingsWindowDrag = (event) => { if (!settingsWindowDrag || (event && event.pointerId !== settingsWindowDrag.pointerId)) return; if (settingsWindowDragFrame) { cancelAnimationFrame(settingsWindowDragFrame); settingsWindowDragFrame = 0; } renderSettingsWindowDrag(); const drag = settingsWindowDrag; settingsWindowDrag = null; moveSettingsWindowTo( drag.previewLeft ?? drag.startLeft, drag.previewTop ?? drag.startTop, drag.width, drag.height, ); settingsPopover.classList.remove('ldp-settings-window-dragging'); try { if (drag.handle.hasPointerCapture(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId); } catch (e) {} }; const closeSettings = () => { closeFontFamilyMenu(); stopSettingsRangeDrag(); stopSettingsColorPick(); stopSettingsWindowDrag(); stopResourceMonitorUpdates(); stopRequestFlowUpdates(); stopUserInfoLastRefreshTicker(); hideSettingHelp(); settingsRangeSaveReminderCategory = ''; fontFamilyManualEditing.clear(); settingsPopover.hidden = true; settingsBtn.setAttribute('aria-expanded', 'false'); resetSettingsDrafts(); }; const saveReaderProfileSettingsDraft = ( category, draftProfiles, sharedDraft, sharingDraft, readProfiles, readShared ) => { const profiles = Object.fromEntries(READER_PROFILE_KEYS.map((profile) => [profile, Object.assign({}, draftProfiles[profile])])); const sharing = currentReaderProfileSharing(); sharing[category] = Object.assign({}, sharingDraft); const categoryName = `${category[0].toUpperCase()}${category.slice(1)}`; setPrefs({ [`${category}Profiles`]: profiles, [`shared${categoryName}Profile`]: Object.assign({}, sharedDraft), profileSharing: sharing, }); settingsRangeSaveReminderCategory = ''; return [readProfiles(), readShared(), Object.assign({}, currentReaderProfileSharing()[category])]; }; const syncSavedReaderProfileSettings = (applySettings, syncControls) => { applySettings(); syncProfileSharingControls(); syncProfileRoutingNotes(); syncControls(); }; const saveFontSettingsDraft = () => { if (!hasUnsavedFontDraft()) return true; closeFontFamilyMenu(); [fontDraftProfiles, sharedFontDraft, fontSharingDraft] = saveReaderProfileSettingsDraft( 'font', fontDraftProfiles, sharedFontDraft, fontSharingDraft, currentFontProfiles, currentSharedFontProfile ); syncSavedReaderProfileSettings(applyFontScales, syncFontControls); return true; }; const saveLayoutSettingsDraft = () => { if (!hasUnsavedLayoutDraft()) return true; if (layoutRatioTotal(currentLayoutDraft()) !== 100) return false; [layoutDraftProfiles, sharedLayoutDraft, layoutSharingDraft] = saveReaderProfileSettingsDraft( 'layout', layoutDraftProfiles, sharedLayoutDraft, layoutSharingDraft, currentLayoutProfiles, currentSharedLayoutProfile ); layoutDraftEditedRegion = ''; syncSavedReaderProfileSettings(() => { if (readerProfileSharingEnabled('layout') || layoutSettingsProfile === activeReaderProfileName()) { applyLayoutRatios(); } }, syncLayoutControls); return true; }; const saveAppearanceSettingsDraft = () => { if (!hasUnsavedAppearanceDraft()) return true; [appearanceDraftProfiles, sharedAppearanceDraft, appearanceSharingDraft] = saveReaderProfileSettingsDraft( 'appearance', appearanceDraftProfiles, sharedAppearanceDraft, appearanceSharingDraft, currentAppearanceProfiles, currentSharedAppearanceProfile ); syncSavedReaderProfileSettings(applyAppearanceColors, syncAppearanceControls); return true; }; const saveJumpHighlightSettingsDraft = () => { if (!hasUnsavedJumpHighlightDraft()) return true; setPrefs(jumpHighlightPrefsPatch(jumpHighlightDraft)); settingsRangeSaveReminderCategory = ''; jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); syncJumpHighlightControls(); return true; }; const saveAllSettingsDrafts = () => { if (!saveLayoutSettingsDraft()) return false; saveFontSettingsDraft(); saveAppearanceSettingsDraft(); saveJumpHighlightSettingsDraft(); return true; }; const requestCloseSettings = () => { const summary = settingsDraftSummary(); if (summary.length) { const total = summary.reduce((count, entry) => count + entry.count, 0); openReaderConfirmDialog(ctx, { title: `${total} 项设置尚未保存`, message: '草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。', details: summary.map((entry) => ({ label: entry.label, value: `${entry.count} 项` })), note: '选择“继续编辑”会回到当前设置页,草稿不会丢失。', icon: 'check', tone: 'primary', cancelLabel: '继续编辑', secondaryLabel: '放弃并关闭', confirmLabel: '保存全部并关闭', onSecondary: closeSettings, onConfirm: () => { if (!saveAllSettingsDrafts()) { showSettingsPanel('layout'); showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。'); return; } closeSettings(); showSelectionToast(`已保存 ${total} 项设置`); }, }); return false; } closeSettings(); return true; }; const closeReaderFloatingPanels = () => { closeFontFamilyMenu(); if (!settingsPopover.hidden) requestCloseSettings(); closeNotifications(); closeHistory(); closeBookmarks(); }; if (!reusingShell) { applyReaderVisualSettings(); applyJumpHighlightSettings(); syncOtherControls(); settingsTabs.forEach((tab) => { tab.addEventListener('click', () => showSettingsPanel(tab.dataset.settingsPanel || 'image')); }); loadingAnimationSelect.addEventListener('change', () => { setPref('loadingAnimation', normalizeReaderLoadingAnimation(loadingAnimationSelect.value)); loadingAnimationPreviewRandomKey = ''; syncLoadingAnimationControls(); }); loadingAnimationSelect.addEventListener('wheel', (event) => event.stopPropagation(), { passive: true }); loadingAnimationReroll.addEventListener('click', () => syncLoadingAnimationControls(true)); settingsNav?.addEventListener('scroll', syncSettingsNavScrollHints, { passive: true }); settingsNavScrollUp?.addEventListener('click', () => scrollSettingsNav(-1)); settingsNavScrollDown?.addEventListener('click', () => scrollSettingsNav(1)); userInfoContent?.addEventListener('click', (event) => { const viewTab = event.target.closest('[data-user-info-view]'); if (viewTab) { const view = viewTab.dataset.userInfoView === 'profile' ? 'profile' : 'connect'; userInfoContent.dataset.userInfoView = view; syncTabSelection(userInfoContent.querySelectorAll('[data-user-info-view]'), 'userInfoView', view); userInfoContent.querySelectorAll('[data-user-info-panel]').forEach((panel) => { panel.hidden = panel.dataset.userInfoPanel !== view; }); } }); userInfoTitleRefresh?.addEventListener('click', () => void loadUserInfoSettings(true)); settingsPopover.addEventListener('pointerdown', (event) => { const row = event.target.closest('.ldp-settings-intro'); if (!row) return; if (event.button !== 0) return; if (event.target.closest('.ldp-user-info-title-refresh')) return; if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return; const rect = settingsPopover.getBoundingClientRect(); closeFontFamilyMenu(); stopSettingsRangeDrag(); stopSettingsColorPick(); hideSettingHelp(); const position = moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height); settingsWindowDrag = { pointerId: event.pointerId, handle: row, startX: event.clientX, startY: event.clientY, clientX: event.clientX, clientY: event.clientY, startLeft: position.left, startTop: position.top, width: rect.width, height: rect.height, }; settingsPopover.classList.add('ldp-settings-window-dragging'); try { row.setPointerCapture(event.pointerId); } catch (e) {} event.preventDefault(); }); settingsPopover.addEventListener('pointermove', (event) => { if (!settingsWindowDrag || event.pointerId !== settingsWindowDrag.pointerId) return; const coalescedEvents = typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : []; const latestEvent = coalescedEvents[coalescedEvents.length - 1] || event; settingsWindowDrag.clientX = latestEvent.clientX; settingsWindowDrag.clientY = latestEvent.clientY; if (!settingsWindowDragFrame) settingsWindowDragFrame = requestAnimationFrame(renderSettingsWindowDrag); event.preventDefault(); }); const stopSettingsPointerInteractions = (event) => { stopSettingsWindowDrag(event); stopSettingsRangeDrag(); }; ['pointerup', 'pointercancel'].forEach((type) => settingsPopover.addEventListener(type, stopSettingsPointerInteractions)); settingsPopover.addEventListener('lostpointercapture', stopSettingsPointerInteractions, true); configExportBtn.addEventListener('click', () => { try { downloadReaderConfigExport(); showSelectionToast('设置配置已导出'); } catch (error) { showSelectionToast('导出失败,请重试'); } }); configImportBtn.addEventListener('click', () => { configFileInput.value = ''; configFileInput.click(); }); configFileInput.addEventListener('change', async () => { const file = configFileInput.files && configFileInput.files[0]; if (!file) return; let importedPrefs; try { importedPrefs = readerPrefsFromConfigExport(JSON.parse(await file.text())); } catch (error) { showSelectionToast('配置文件无效,请选择由阅读器导出的 JSON 文件'); return; } openReaderConfirmDialog(ctx, { title: '导入这份设置配置?', message: `将使用“${file.name}”覆盖当前阅读器设置,并刷新页面。`, note: '浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。', confirmLabel: '导入并刷新', onConfirm: () => replacePrefsAndReload(importedPrefs, '导入失败,浏览器未能保存设置'), }); }); configResetBtn.addEventListener('click', () => { openReaderConfirmDialog(ctx, { title: '恢复全部默认设置?', message: '图片、字体、布局、浮窗、外观、闪烁、动效、性能和其他功能设置都会恢复默认,并刷新页面。', note: '浏览历史和缓存不会被删除。', confirmLabel: '恢复并刷新', onConfirm: () => replacePrefsAndReload(DEFAULT_PREFS, '恢复失败,浏览器未能保存设置'), }); }); cacheSelects.forEach((input) => input.addEventListener('change', () => { cacheClearBtn.disabled = !Array.from(cacheSelects).some((candidate) => candidate.checked); })); cacheClearBtn.addEventListener('click', async () => { const selected = Array.from(cacheSelects).filter((input) => input.checked).map((input) => input.value); if (!selected.length) return; cacheClearBtn.disabled = true; await clearSelectedCaches(selected); cacheSelects.forEach((input) => { input.checked = false; }); await syncCacheControls(); }); performancePresetButtons.forEach((button) => { button.addEventListener('click', () => { const name = button.dataset.performancePreset; if (name === 'custom') { const firstInput = performanceInputs[0]; if (firstInput) firstInput.focus(); return; } setPerformancePreset(name); performanceSettingsChanged = true; syncPerformanceControls(); }); }); performanceInputs.forEach((input) => { input.addEventListener('change', () => { setPerformanceValue(input.dataset.performanceKey, input.value); performanceSettingsChanged = true; syncPerformanceControls(); }); }); performanceResetBtn.addEventListener('click', () => { setPerformancePreset('balanced'); performanceSettingsChanged = true; syncPerformanceControls(); }); const previewJumpHighlightControls = () => { const config = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => [field.key, jumpHighlightControls[field.key].input.value])); jumpHighlightDraft = jumpHighlightConfigFromPrefs(Object.assign({}, PREFS, jumpHighlightPrefsPatch(config))); syncJumpHighlightControls(); }; jumpHighlightInputs.forEach((input) => input.addEventListener('input', previewJumpHighlightControls)); jumpHighlightApplyBtn.addEventListener('click', saveJumpHighlightSettingsDraft); [ [historyButtonsAlwaysVisibleInput, (checked) => setPref('historyButtonsAlwaysVisible', checked), syncOtherControls], [openTopicsAtFirstPostInput, (checked) => setPref('openTopicsAtFirstPost', checked), syncOtherControls], [lightboxOriginalByDefaultInput, (checked) => setPref('lightboxOriginalByDefault', checked)], [lightboxCommentsExpandedInput, (checked) => setPref('lightboxCommentsExpandedByDefault', checked)], [lightboxDescriptionExpandedInput, (checked) => setPref('lightboxDescriptionExpanded', checked)], [fontRenderingEnabledInput, (checked) => setPref('fontRenderingEnabled', checked)], [fontRenderingOnHostInput, (checked) => setPref('fontRenderingOnHost', checked)], [readerWindowPinInput, (checked) => readerWindow.setPinned(checked), syncReaderWindowControls], [readerWindowLockInput, (checked) => readerWindow.setLocked(checked), syncReaderWindowControls], ].forEach(([input, save, sync]) => input.addEventListener('change', () => { save(input.checked); if (sync) sync(); })); historyEdgeTriggerRange.addEventListener('input', () => { setPref('historyEdgeTriggerPercent', normalizeHistoryEdgeTriggerPercent(historyEdgeTriggerRange.value)); syncOtherControls(); }); historySortModeSelect.addEventListener('change', () => { setPref('historySortMode', historySortModeSelect.value); syncOtherControls(); syncHistorySortToggle(); overlay._ldpRefreshReaderHistoryNavigation?.(); }); const saveNestedReplyDisplayPreference = (changedInput) => { let expandAll = expandNestedRepliesByDefaultInput.checked; let expandLeaf = expandLeafNestedRepliesInput.checked; if (changedInput === expandNestedRepliesByDefaultInput && !expandAll) expandLeaf = true; else if (changedInput === expandLeafNestedRepliesInput && !expandLeaf) expandAll = true; setPrefs({ expandNestedRepliesByDefault: expandAll, expandLeafNestedReplies: expandLeaf, }); applyNestedReplyDisplayPreference(readerShell.activeContext); syncOtherControls(); }; [expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput].forEach((input) => input.addEventListener('change', () => saveNestedReplyDisplayPreference(input))); const saveBoostCopyControls = () => { setPrefs(boostCopyDraftFromControls()); syncOtherControls(); }; boostCopyModeSelect.addEventListener('change', saveBoostCopyControls); [boostCopyPrefixInput, boostCopyCounterMarkerInput, boostCopyCounterStepInput, boostCopyFixedSuffixInput] .forEach((input) => { input.addEventListener('input', () => syncBoostCopyPreview()); input.addEventListener('change', saveBoostCopyControls); }); notificationPanel.markAll.addEventListener('click', async () => { if (notificationPanel.markAll.disabled || notificationPanel.markAll.dataset.busy === '1') return; notificationPanel.markAll.dataset.busy = '1'; notificationPanel.markAll.disabled = true; const previousHtml = notificationPanel.markAll.innerHTML; notificationPanel.markAll.innerHTML = `${icon('loader')}<span>处理中</span>`; try { const groupKey = Object.prototype.hasOwnProperty.call(NOTIFICATION_GROUPS, notificationState.group) ? notificationState.group : 'all'; const group = NOTIFICATION_GROUPS[groupKey]; const dismissParams = group.types.length ? { dismiss_types: group.types.join(',') } : undefined; await apiSend(`${BASE}/notifications/mark-read`, 'PUT', dismissParams); const markedCount = markCachedNotificationsRead(groupKey); if (ME_CURRENT_USER && groupKey === 'all') { ['all_unread_notifications_count', 'unread_notifications', 'unread_notification_count', 'unread_high_priority_notifications'] .forEach((key) => { if (Object.prototype.hasOwnProperty.call(ME_CURRENT_USER, key)) ME_CURRENT_USER[key] = 0; }); } else if (ME_CURRENT_USER && ME_CURRENT_USER.grouped_unread_notifications) { group.typeIds.forEach((typeId) => { delete ME_CURRENT_USER.grouped_unread_notifications[typeId]; }); } notificationPanel.list.querySelectorAll('.ldp-notification-item.unread') .forEach((item) => { item.classList.remove('unread'); const readState = item.querySelector('.ldp-notification-read-state'); if (readState) readState.textContent = '已读'; }); showSelectionToast(groupKey === 'all' ? '消息已全部标为已读' : `${group.label}消息已全部标为已读(${markedCount} 条)`); } catch (error) { alert(`标记已读失败:${error.message}`); } finally { notificationPanel.markAll.dataset.busy = '0'; notificationPanel.markAll.innerHTML = previousHtml; notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0; } }); notificationsBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const opening = notificationsPopover.hidden; if (!requestCloseSettings()) return; closeHistory(); closeBookmarks(); notificationsPopover.hidden = !opening; notificationsBtn.setAttribute('aria-expanded', String(opening)); if (!opening) return; positionNotificationsPopover(); void ensureMe(true); loadNotificationPage(notificationState.group, notificationState.page); }); const bindReaderAuxPanelToggle = (button, popover, closePanel, closePeer, openPanel) => { button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const opening = popover.hidden; closeNotifications(); closePeer(); if (!requestCloseSettings()) return; if (!opening) return closePanel(); popover.hidden = false; button.setAttribute('aria-expanded', 'true'); openPanel(); }); }; bindReaderAuxPanelToggle(historyPanel.toggle, historyPanel.popover, closeHistory, closeBookmarks, renderHistoryPage); bindReaderAuxPanelToggle(bookmarksPanel.toggle, bookmarksPanel.popover, closeBookmarks, closeHistory, () => { positionBookmarksPopover(); loadBookmarksPage(); }); notificationTabs.forEach((tab) => { tab.addEventListener('click', () => { notificationState.group = tab.dataset.notificationGroup || 'all'; notificationState.page = 0; loadNotificationPage(notificationState.group, notificationState.page); }); }); const cancelNotificationSearch = bindReaderPanelSearch(notificationPanel.search, notificationPanel.searchClear, (query) => { notificationState.query = query; notificationState.page = 0; loadNotificationPage(notificationState.group, 0); }); bindReaderPanelPager(notificationPanel.pagePrev, notificationPanel.pageNext, notificationState, () => loadNotificationPage(notificationState.group, notificationState.page)); historyPanel.sortToggle.addEventListener('click', () => { setPref('historySortMode', PREFS.historySortMode === 'recent-viewed' ? 'first-viewed' : 'recent-viewed'); historyState.page = 0; historySelection.clear(); syncOtherControls(); renderHistoryPage(); overlay._ldpRefreshReaderHistoryNavigation?.(); historyPanel.sortToggle.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true })); }); bindReaderPanelMultiMode( historyState, historySelection, historyPanel.multi, historyPanel.multiDone, historyPanel.selectScope, renderHistoryPage ); historyPanel.selectToggle.addEventListener('click', () => { const orderedEntries = orderedTopicHistory(); const filteredEntries = historyState.query ? orderedEntries.filter((entry) => historyMatchesSearch(entry, historyState.query)) : orderedEntries; const start = historyState.page * HISTORY_PAGE_SIZE; const scopeEntries = historyState.scope === 'all' ? filteredEntries : filteredEntries.slice(start, start + HISTORY_PAGE_SIZE); const scopeIds = scopeEntries.map((entry) => Number(entry.topicId)).filter((id) => id > 0); toggleReaderPanelSelection(historySelection, scopeIds); renderHistoryPage(); }); bindReaderPanelPager(historyPanel.pagePrev, historyPanel.pageNext, historyState, renderHistoryPage); const cancelHistorySearch = bindReaderPanelSearch(historyPanel.search, historyPanel.searchClear, (query) => { historyState.query = query; historyState.page = 0; historySelection.clear(); renderHistoryPage(); }); historyPanel.deleteSelected.addEventListener('click', () => { const selectedCount = historySelection.size; if (!selectedCount) return; openReaderConfirmDialog(ctx, { title: '删除所选浏览历史?', message: `将删除选中的 ${selectedCount} 条阅读器浏览历史。`, note: '不会清除浏览器自身的访问历史。', icon: 'trash', confirmLabel: `删除 ${selectedCount} 条`, onConfirm: () => { writeTopicHistory(readTopicHistory().filter((entry) => !historySelection.has(Number(entry.topicId)))); historySelection.clear(); renderHistoryPage(); showSelectionToast(`已删除 ${selectedCount} 条浏览历史`); }, }); }); historyPanel.clear.addEventListener('click', () => { const historyCount = readTopicHistory().length; if (!historyCount) return; openReaderConfirmDialog(ctx, { title: '清空浏览历史?', message: `将删除全部 ${historyCount} 条阅读器浏览历史。`, note: '此操作无法撤销,但不会清除浏览器自身的访问历史。', icon: 'trash', confirmLabel: '清空全部', onConfirm: () => { writeTopicHistory([]); historySelection.clear(); historyPanel.search.value = ''; historyPanel.searchClear.hidden = true; historyState.query = ''; historyState.page = 0; renderHistoryPage(); showSelectionToast('浏览历史已清空'); }, }); }); bookmarksTabs.forEach((tab) => { tab.addEventListener('click', () => { const requestedType = tab.dataset.bookmarkType; const nextType = ['Topic', 'Post', 'Reaction'].includes(requestedType) ? requestedType : 'Topic'; if (nextType === bookmarksState.type) return; bookmarksState.type = nextType; bookmarksState.page = 0; bookmarksState.multi = false; clearBookmarksSelectionScope(); if (nextType !== 'Reaction' || bookmarksState.reactionLoadedAt <= 0) renderBookmarksPage(); loadBookmarksPage(); }); }); let bookmarkTabDrag = null; let suppressBookmarkTabClick = false; const finishBookmarkTabDrag = (event) => { if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return; const { tab, moved } = bookmarkTabDrag; bookmarkTabDrag = null; tab.classList.remove('ldp-bookmark-tab-dragging'); if (!moved) return; suppressBookmarkTabClick = true; setPref('bookmarkTabOrder', Array.from(bookmarksTabList.children, (item) => item.dataset.bookmarkType)); }; bookmarksTabList.addEventListener('click', (event) => { if (!suppressBookmarkTabClick) return; suppressBookmarkTabClick = false; event.preventDefault(); event.stopImmediatePropagation(); }, true); bookmarksTabList.addEventListener('pointerdown', (event) => { if (event.pointerType !== 'mouse' || event.button !== 0) return; const tab = event.target.closest('.ldp-bookmark-tab'); if (!tab) return; bookmarkTabDrag = { tab, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, }; try { tab.setPointerCapture(event.pointerId); } catch (e) {} }); bookmarksTabList.addEventListener('pointermove', (event) => { if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return; if (!bookmarkTabDrag.moved && Math.hypot( event.clientX - bookmarkTabDrag.startX, event.clientY - bookmarkTabDrag.startY ) < 5) return; bookmarkTabDrag.moved = true; bookmarkTabDrag.tab.classList.add('ldp-bookmark-tab-dragging'); event.preventDefault(); const siblings = Array.from(bookmarksTabList.children).filter((item) => item !== bookmarkTabDrag.tab); const next = siblings.find((item) => { const rect = item.getBoundingClientRect(); return event.clientX < rect.left + rect.width / 2; }); bookmarksTabList.insertBefore(bookmarkTabDrag.tab, next || null); }); bookmarksTabList.addEventListener('pointerup', finishBookmarkTabDrag); bookmarksTabList.addEventListener('pointercancel', finishBookmarkTabDrag); bookmarksReactionFilters.addEventListener('click', (e) => { const button = e.target.closest('[data-reaction-filter]'); if (!button) return; bookmarksState.reactionFilter = String(button.dataset.reactionFilter || ''); bookmarksState.page = 0; renderBookmarksPage(); }); bindReaderPanelMultiMode( bookmarksState, bookmarksSelection, bookmarksPanel.multi, bookmarksPanel.multiDone, bookmarksPanel.selectScope, renderBookmarksPage, () => { bookmarksState.allIds = null; } ); bookmarksPanel.selectToggle.addEventListener('click', () => { let scopeIds = bookmarksState.entries.map((entry) => Number(entry.id)).filter((id) => id > 0); if (bookmarksState.scope === 'all') { if (!bookmarksState.allIds) { bookmarksState.allIds = bookmarkTypeEntries() .map((entry) => Number(entry.id)) .filter((bookmarkId) => bookmarkId > 0); } scopeIds = bookmarksState.allIds || []; } toggleReaderPanelSelection(bookmarksSelection, scopeIds); renderBookmarksPage(); }); bindReaderPanelPager(bookmarksPanel.pagePrev, bookmarksPanel.pageNext, bookmarksState, renderBookmarksPage); const cancelBookmarksSearch = bindReaderPanelSearch(bookmarksPanel.search, bookmarksPanel.searchClear, (query) => { bookmarksState.query = query; bookmarksState.page = 0; clearBookmarksSelectionScope(); if (bookmarksState.type === 'Reaction' && bookmarksState.reactionLoadedAt > 0) renderBookmarksPage(); else loadBookmarksPage(); }, 250); readerShell.cancelBookmarksSearch = cancelBookmarksSearch; bookmarksPanel.deleteSelected.addEventListener('click', async () => { const bookmarkIds = Array.from(bookmarksSelection); if (!bookmarkIds.length || bookmarksPanel.deleteSelected.dataset.busy === '1' || !window.confirm(`确定取消选中的 ${bookmarkIds.length} 条收藏吗?`)) return; bookmarksPanel.deleteSelected.dataset.busy = '1'; bookmarksPanel.deleteSelected.disabled = true; const visibleRemoved = bookmarksState.entries.filter((entry) => bookmarksSelection.has(Number(entry.id))); const selectedRemoved = bookmarksState.allEntries.filter((entry) => bookmarksSelection.has(Number(entry.id))); const removedAllMatches = Array.isArray(bookmarksState.allIds) && bookmarksState.allIds.length > 0 && bookmarksState.allIds.every((bookmarkId) => bookmarksSelection.has(bookmarkId)); try { await apiSend(`${BASE}/bookmarks/bulk`, 'PUT', { bookmark_ids: bookmarkIds, operation: { type: 'delete' }, }, null, { jsonBody: true }); selectedRemoved.forEach(applyRemovedBookmarkState); const clearedPage = bookmarksState.entries.length > 0 && visibleRemoved.length === bookmarksState.entries.length; if (removedAllMatches) bookmarksState.page = 0; else if (clearedPage && bookmarksState.page > 0) bookmarksState.page -= 1; clearBookmarksSelectionScope(); await loadBookmarksPage(); showSelectionToast(`已取消 ${bookmarkIds.length} 条收藏`); } catch (error) { alert(`批量取消收藏失败:${error.message}`); } finally { bookmarksPanel.deleteSelected.dataset.busy = '0'; if (!bookmarksPanel.popover.hidden) renderBookmarksPage(); } }); notificationsPopover.addEventListener('click', (e) => e.stopPropagation()); bindFloatingSurfaceWheel(notificationsPopover); bindReaderPanelSelectionList(historyPanel.list, '.ldp-history-select-input', '[data-history-topic-id]', 'historyTopicId', historySelection, renderHistoryPage); historyPanel.popover.addEventListener('click', (e) => { e.stopPropagation(); const item = e.target.closest('[data-history-topic-id]'); if (!item) return; if (e.target.closest('.ldp-history-select')) return; e.preventDefault(); const targetTopicId = Number(item.dataset.historyTopicId); const targetPostNumber = Math.max(1, Number(item.dataset.historyPostNumber) || 1); if (e.target.closest('.ldp-history-delete')) { writeTopicHistory(readTopicHistory().filter((entry) => Number(entry.topicId) !== targetTopicId)); historySelection.delete(targetTopicId); renderHistoryPage(); showSelectionToast('已删除这条浏览历史'); return; } if (targetTopicId > 0) openModal(targetTopicId, { targetPostNumber }); }); bindFloatingSurfaceWheel(historyPanel.popover); bindReaderPanelSelectionList(bookmarksPanel.list, '.ldp-bookmark-select-input', '[data-bookmark-id]', 'bookmarkId', bookmarksSelection, renderBookmarksPage); bookmarksPanel.popover.addEventListener('click', async (e) => { e.stopPropagation(); const reactionItem = e.target.closest('[data-reaction-record]'); if (reactionItem) { e.preventDefault(); const targetTopicId = Number(reactionItem.dataset.reactionTopicId); const targetPostNumber = Math.max(1, Number(reactionItem.dataset.reactionPostNumber) || 1); if (targetTopicId > 0) { closeBookmarks(); openModal(targetTopicId, { targetPostNumber }); } return; } const item = e.target.closest('[data-bookmark-id]'); if (!item || e.target.closest('.ldp-bookmark-select')) return; e.preventDefault(); const bookmarkId = Number(item.dataset.bookmarkId); const entry = bookmarksState.entries.find((candidate) => Number(candidate.id) === bookmarkId); if (!entry) return; if (e.target.closest('.ldp-bookmark-delete')) { const deleteBtn = e.target.closest('.ldp-bookmark-delete'); if (deleteBtn.dataset.busy === '1') return; deleteBtn.dataset.busy = '1'; deleteBtn.disabled = true; try { await apiSend(`${BASE}/bookmarks/${bookmarkId}`, 'DELETE'); applyRemovedBookmarkState(entry); bookmarksSelection.delete(bookmarkId); bookmarksState.allIds = null; if (bookmarksState.entries.length === 1 && bookmarksState.page > 0) bookmarksState.page -= 1; await loadBookmarksPage(); showSelectionToast('已取消这条收藏'); } catch (error) { alert(`取消收藏失败:${error.message}`); deleteBtn.dataset.busy = '0'; deleteBtn.disabled = false; } return; } const target = bookmarkTarget(entry); if (target.topicId > 0) { closeBookmarks(); openModal(target.topicId, { targetPostNumber: target.postNumber }); } }); bindFloatingSurfaceWheel(bookmarksPanel.popover); themeButtons.forEach((button) => button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); transitionReaderTheme(button.dataset.readerThemeMode); })); settingsBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const nextHidden = !settingsPopover.hidden; closeNotifications(); closeHistory(); closeBookmarks(); if (nextHidden) { closeFontFamilyMenu(); requestCloseSettings(); return; } settingsPopover.hidden = false; fontFamilyManualEditing.clear(); keepSettingsWindowVisible(); settingsBtn.setAttribute('aria-expanded', 'true'); settingsRangeSaveReminderCategory = ''; imageSettingsProfile = activeReaderProfileName(); fontSettingsProfile = activeReaderProfileName(); layoutSettingsProfile = activeReaderProfileName(); appearanceSettingsProfile = activeReaderProfileName(); showSettingsPanel('user'); syncProfileSharingControls(); requestAnimationFrame(syncSettingsNavScrollHints); }); settingsCloseBtn.addEventListener('click', (event) => { event.preventDefault(); requestCloseSettings(); }); settingsPopover.addEventListener('click', (e) => e.stopPropagation()); bindFloatingSurfaceWheel(settingsPopover); settingsPopover.addEventListener('pointerover', (event) => { const target = event.target.closest('[data-setting-help]'); if (!target || (event.relatedTarget && target.contains(event.relatedTarget))) return; showSettingHelp(target); }); const scheduleSettingHelpHide = () => { requestAnimationFrame(() => { if (activeSettingHelpTarget && !activeSettingHelpTarget.matches(':hover') && !activeSettingHelpTarget.matches(':focus-within')) hideSettingHelp(); }); }; settingsPopover.addEventListener('pointerout', (event) => { if (!activeSettingHelpTarget) return; if (event.relatedTarget && activeSettingHelpTarget.contains(event.relatedTarget)) return; scheduleSettingHelpHide(); }); settingsPopover.addEventListener('focusin', (event) => { const target = event.target.closest('[data-setting-help]'); if (target) showSettingHelp(target); }); settingsPopover.addEventListener('focusout', scheduleSettingHelpHide); settingsPanel.addEventListener('scroll', () => { hideSettingHelp(); closeFontFamilyMenu(); closeColorPickerPopover(); }, { passive: true }); settingsPopover.addEventListener('pointerdown', (event) => { if (!fontFamilyMenu.hidden && !event.target.closest('.ldp-font-family-picker')) closeFontFamilyMenu(); const color = event.target.closest('input[type="color"]'); if (color && !color.disabled && event.button === 0) { hideSettingHelp(); stopSettingsRangeDrag(); stopSettingsColorPick(); settingsColorPickRow = color.closest('.ldp-setting-row'); if (settingsColorPickRow) settingsColorPickRow.classList.add('ldp-color-pick-active'); settingsPopover.classList.add('ldp-color-picking'); return; } stopSettingsColorPick(); const range = event.target.closest('input[type="range"]'); if (!range || range.disabled || event.button !== 0) return; hideSettingHelp(); stopSettingsRangeDrag(); settingsRangeDragRow = range.closest('.ldp-setting-row'); if (settingsRangeDragRow) settingsRangeDragRow.classList.add('ldp-range-drag-active'); settingsPopover.classList.add('ldp-range-dragging'); try { range.setPointerCapture(event.pointerId); } catch (e) {} }); settingsPopover.addEventListener('change', (event) => { if (event.target.matches('input[type="color"]')) stopSettingsColorPick(); if (event.target.matches('input[type="range"]')) remindUnsavedRangeSetting(event.target); }); settingsPopover.addEventListener('focusout', (event) => { if (event.target.matches('input[type="color"]')) stopSettingsColorPick(); }); modal.addEventListener('click', closeReaderFloatingPanels); const updateDraftProfileSharing = (category, field, enabled, input) => { let syncControls; let applyPreview; if (category === 'font') { const values = currentFontDraft(); fontSharingDraft = Object.assign({}, fontSharingDraft, { [field]: enabled }); if (enabled) { sharedFontDraft = Object.assign({}, sharedFontDraft, { [field]: values[field] }); const familyScope = FONT_SCOPE_KEYS.find((scope) => fontScopeConfig(scope).family === field); if (familyScope) { const customFamilyField = fontScopeConfig(familyScope).customFamily; sharedFontDraft[customFamilyField] = values[customFamilyField]; } } syncControls = syncFontControls; applyPreview = applyFontScales; } else if (category === 'layout') { const values = currentLayoutDraft(); layoutSharingDraft = Object.assign({}, layoutSharingDraft, { [field]: enabled }); if (enabled) sharedLayoutDraft = Object.assign({}, sharedLayoutDraft, { [field]: values[field] }); syncControls = syncLayoutControls; applyPreview = applyLayoutRatios; } else if (category === 'appearance') { const values = currentAppearanceDraft(); appearanceSharingDraft = Object.assign({}, appearanceSharingDraft, { [field]: enabled }); if (enabled) sharedAppearanceDraft = Object.assign({}, sharedAppearanceDraft, { [field]: values[field] }); syncControls = syncAppearanceControls; applyPreview = applyAppearanceColors; } else return false; syncProfileSharingControls(); syncProfileRoutingNotes(); syncControls(); applyPreview(); const fieldLabel = input.getAttribute('aria-label').replace('三种形态共享', ''); showSelectionToast(enabled ? `${fieldLabel}将由三种形态共享;点击“确认应用”后保存。` : `${fieldLabel}将恢复按形态分别设置;点击“确认应用”后保存。`); return true; }; profileSharingInputs.forEach((input) => input.addEventListener('change', () => { const category = input.dataset.profileSharingCategory; const field = input.dataset.profileSharingField; if (!READER_PROFILE_SHARING_KEYS.includes(category) || !READER_PROFILE_SHARING_FIELDS[category].includes(field)) return; const enabled = input.checked; const wasEnabled = category === 'font' ? fontDraftSettingShared(field) : category === 'layout' ? layoutDraftSettingShared(field) : category === 'appearance' ? appearanceDraftSettingShared(field) : readerProfileSettingShared(category, field); if (enabled === wasEnabled) return; if (updateDraftProfileSharing(category, field, enabled, input)) return; if (category !== 'image') return; const sharing = currentReaderProfileSharing(); sharing[category][field] = enabled; const patch = { profileSharing: sharing }; if (enabled) patch.sharedImageProfile = Object.assign({}, currentImageProfile(imageSettingsProfile)); setPrefs(patch); syncProfileSharingControls(); syncProfileRoutingNotes(); syncImageScaleControls(); applyImageScale(); const fieldLabel = input.getAttribute('aria-label').replace('三种形态共享', ''); showSelectionToast(enabled ? `${fieldLabel}已由三种形态共享;原独立值已保留。` : `${fieldLabel}已恢复按形态分别设置。`); })); settingsPopover.addEventListener('click', (event) => { const tab = event.target.closest( '[data-image-profile],[data-font-profile],[data-layout-profile],[data-appearance-profile]' ); if (!tab) return; const profileType = ['image', 'font', 'layout', 'appearance'] .find((type) => tab.dataset[`${type}Profile`] != null); const profile = profileType && tab.dataset[`${profileType}Profile`]; if (!READER_PROFILE_KEYS.includes(profile)) return; selectSettingsProfile[profileType](profile, true); }); const saveCurrentImageProfile = (values) => { if (readerProfileSettingShared('image', 'scale')) { setPrefs({ sharedImageProfile: values }); return; } const profiles = currentImageProfiles(); profiles[imageSettingsProfile] = values; setPrefs({ imageProfiles: profiles }); }; imageScaleRange.addEventListener('input', () => { const next = normalizeImageScalePercent(imageScaleRange.value, currentImageScalePercent(imageSettingsProfile)); saveCurrentImageProfile(Object.assign({}, currentImageProfile(imageSettingsProfile), { preset: 'custom', custom: next, })); syncImageScaleControls(); applyImageScale(); }); imageScaleReset.addEventListener('click', () => { saveCurrentImageProfile(Object.assign({}, IMAGE_PROFILE_DEFAULTS[imageSettingsProfile])); syncImageScaleControls(); applyImageScale(); }); fontScopeTabs.forEach((tab) => { tab.addEventListener('click', () => { const scope = tab.dataset.fontScopeTab; if (!FONT_SCOPE_KEYS.includes(scope) || scope === fontSettingsScope) return; closeFontFamilyMenu(); fontSettingsScope = scope; syncFontControls(); }); }); fontFamilyTriggers.forEach((trigger) => { trigger.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); hideSettingHelp(); const scope = trigger.dataset.fontScope; if (!fontFamilyMenu.hidden && activeFontFamilyScope === scope) closeFontFamilyMenu(true); else openFontFamilyMenu(scope); }); trigger.addEventListener('keydown', (event) => { if (event.key !== 'ArrowDown') return; event.preventDefault(); openFontFamilyMenu(trigger.dataset.fontScope); }); }); fontFamilySearch.addEventListener('input', applyFontFamilySearch); fontFamilyOptions.addEventListener('click', (event) => { const option = event.target.closest('.ldp-font-family-option'); if (!option) return; const config = fontScopeConfig(); const values = Object.assign({}, fontValuesForScope()); if (option.dataset.fontKind === 'preset') { values[config.family] = normalizeFontFamily(option.dataset.fontValue, values[config.family]); values[config.customFamily] = ''; } else { values[config.family] = 'custom'; values[config.customFamily] = normalizeCustomFontFamily(option.dataset.fontValue, values[config.customFamily]); } fontFamilyManualEditing.delete(activeFontFamilyScope); if (activeFontFamilyScope === 'host') setPrefs({ hostFontFamily: values.hostFontFamily, hostFontCustomFamily: values.hostFontCustomFamily, }); else setCurrentFontDraft(values); closeFontFamilyMenu(true); syncFontControls(); if (activeFontFamilyScope !== 'host') applyFontScales(); }); fontFamilyManualButton.addEventListener('click', () => { const scope = activeFontFamilyScope; const config = fontScopeConfig(scope); const values = Object.assign({}, fontValuesForScope(scope), { [config.family]: 'custom' }); fontFamilyManualEditing.add(scope); if (scope === 'host') setPref(config.family, 'custom'); else setCurrentFontDraft(values); closeFontFamilyMenu(); syncFontControls(); if (scope !== 'host') applyFontScales(); const input = Array.from(fontFamilyCustomInputs).find((candidate) => candidate.dataset.fontScope === scope); input.focus(); input.select(); }); fontFamilyMenu.addEventListener('keydown', (event) => { handleSearchableMenuKeyDown(event, fontFamilySearch, fontFamilyOptions, '.ldp-font-family-option:not([hidden])', closeFontFamilyMenu); }); fontFamilyMenu.addEventListener('click', (event) => event.stopPropagation()); fontFamilyMenu.addEventListener('pointerdown', (event) => event.stopPropagation()); bindFloatingSurfaceWheel(fontFamilyMenu); fontFamilyCustomInputs.forEach((input) => input.addEventListener('input', () => { const scope = input.dataset.fontScope; const config = fontScopeConfig(scope); const values = Object.assign({}, fontValuesForScope(scope)); values[config.family] = 'custom'; values[config.customFamily] = normalizeCustomFontFamily(input.value, values[config.customFamily]); if (scope === 'host') setPrefs({ hostFontFamily: values.hostFontFamily, hostFontCustomFamily: values.hostFontCustomFamily, }); else setCurrentFontDraft(values); syncFontControls(); if (scope !== 'host') applyFontScales(); })); [ [fontWeightSelects, 'change', 'weight', normalizeFontWeight], [fontColorInputs, 'input', 'color', normalizeHexColor], ].forEach(([inputs, eventName, configField, normalizeValue]) => inputs.forEach((input) => { input.addEventListener(eventName, () => { const scope = input.dataset.fontScope; const config = fontScopeConfig(scope); const values = Object.assign({}, fontValuesForScope(scope)); const field = config[configField]; values[field] = normalizeValue(input.value, values[field]); if (scope === 'host') setPref(field, values[field]); else setCurrentFontDraft(values); syncFontControls(); if (scope !== 'host') applyFontScales(); }); })); fontScaleSettings.forEach(({ input, key, normalize }) => input.addEventListener('input', () => { const values = Object.assign({}, currentFontDraft()); values[key] = normalize(input.value, values[key]); setCurrentFontDraft(values); syncFontControls(); applyFontScales(); })); hostEmbeddedSizeInputs.forEach((input) => input.addEventListener('input', () => { const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize]; if (!config) return; setPref(config.pref, normalizeHostEmbeddedSizeScale(input.value)); syncFontControls(); })); hostEmbeddedSizeResetButtons.forEach((button) => button.addEventListener('click', () => { const config = HOST_EMBED_SIZE_SETTINGS[button.dataset.hostEmbedSizeReset]; if (!config) return; setPref(config.pref, config.defaultScale); syncFontControls(); })); fontFieldResetButtons.forEach((button) => button.addEventListener('click', () => { const host = button.dataset.hostFontReset != null; const profileField = button.dataset.fontFieldReset; const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField)); if (!scope) return; const config = fontScopeConfig(scope); const field = host ? config[button.dataset.hostFontReset] : profileField; const defaults = host ? HOST_FONT_DEFAULTS : currentFontDefaults(); if (!Object.hasOwn(defaults, field)) return; const patch = { [field]: defaults[field] }; if (field === config.family) { patch[config.customFamily] = defaults[config.customFamily]; fontFamilyManualEditing.delete(scope); } if (host) setPrefs(patch); else setCurrentFontDraft(Object.assign({}, currentFontDraft(), patch)); syncFontControls(); if (!host) applyFontScales(); })); fontResetBtn.addEventListener('click', () => { closeFontFamilyMenu(); fontFamilyManualEditing.clear(); setPrefs(HOST_FONT_DEFAULTS); setCurrentFontDraft(Object.assign({}, currentFontDefaults())); syncFontControls(); applyFontScales(); }); fontApplyBtn.addEventListener('click', saveFontSettingsDraft); layoutRatioInputs.forEach((input) => { input.addEventListener('input', () => { const name = input.dataset.layoutRegion; if (!LAYOUT_REGION_KEYS.includes(name)) return; const ratios = Object.assign({}, currentLayoutDraft()); const desired = Number(input.value); const maximum = layoutDraftSharingEnabled() ? 100 - LAYOUT_REGION_KEYS.reduce((sum, other) => sum + (other === name ? 0 : LAYOUT_MIN_RATIOS[other]), 0) : Math.max(LAYOUT_MIN_RATIOS[name], layoutRatioMax(ratios, name)); ratios[name] = roundLayoutRatio(Math.min( maximum, Math.max(LAYOUT_MIN_RATIOS[name], Number.isFinite(desired) ? desired : ratios[name]) )); layoutDraftEditedRegion = name; if (layoutDraftSharingEnabled()) { if (layoutDraftSettingShared(name)) { sharedLayoutDraft = Object.assign({}, sharedLayoutDraft, { [name]: ratios[name] }); } else { layoutDraftProfiles[layoutSettingsProfile] = Object.assign( {}, layoutDraftProfiles[layoutSettingsProfile], { [name]: ratios[name] } ); } const effectiveRatios = currentLayoutDraft(); if (layoutRatioTotal(effectiveRatios) !== 100) { setCurrentLayoutDraft(rebalanceLayoutRatiosAfterChange(ratios, name, layoutSharingDraft)); } } else { setCurrentLayoutDraft(normalizeLayoutRatios(ratios, currentLayoutDefaults())); } applyLayoutRatios(); syncLayoutControls(); }); }); layoutResetBtn.addEventListener('click', () => { layoutDraftEditedRegion = ''; setCurrentLayoutDraft(Object.assign({}, currentLayoutDefaults())); applyLayoutRatios(); syncLayoutControls(); }); layoutApplyBtn.addEventListener('click', saveLayoutSettingsDraft); const applyReaderWindowInputs = () => { readerWindow.setGeometry( readerWindowWidthInput.value, readerWindowHeightInput.value, readerWindowXInput.value, readerWindowYInput.value ); syncReaderWindowControls(); }; readerWindowWidthInput.addEventListener('change', applyReaderWindowInputs); readerWindowHeightInput.addEventListener('change', applyReaderWindowInputs); readerWindowXInput.addEventListener('change', applyReaderWindowInputs); readerWindowYInput.addEventListener('change', applyReaderWindowInputs); readerWindowResetBtn.addEventListener('click', () => { readerWindow.reset(); syncReaderWindowControls(); }); const setAppearanceDraftValue = (key, value) => { setCurrentAppearanceDraft(Object.assign({}, currentAppearanceDraft(), { [key]: value })); }; appearanceControlSettings.forEach(({ input, key, normalize: normalizeValue, apply: applyPreview }) => { input.addEventListener('input', () => { setAppearanceDraftValue(key, normalizeValue(input.value)); applyPreview(); syncAppearanceControls(); }); }); colorResetButtons.forEach((button) => button.addEventListener('click', () => { const key = button.dataset.colorReset; const defaultValue = currentAppearanceDefaults()[key]; if (!defaultValue) return; setAppearanceDraftValue(key, defaultValue); applyAppearanceColors(); syncAppearanceControls(); })); appearanceApplyBtn.addEventListener('click', saveAppearanceSettingsDraft); readerShell.cancelShellFrames = () => { if (colorPickerCommitFrame) cancelAnimationFrame(colorPickerCommitFrame); colorPickerCommitFrame = 0; pendingColorPickerCommit = null; if (settingsWindowDragFrame) cancelAnimationFrame(settingsWindowDragFrame); settingsWindowDragFrame = 0; settingsWindowDrag = null; cancelNotificationSearch(); cancelHistorySearch(); cancelBookmarksSearch(); }; } let ctx = null; const tracker = createReadTracker(topicId, body, postRequestScheduler, (postNumbers) => { markReaderPostsRead(postNumbers, ctx); }); ctx = { topicId, op: null, topicData: null, loader, modal, commentsEl, commentsHeader, countEl, emptyEl, scrollRoot: body, performance, readerSession, isPostVoting: !!(cachedTopicPreview && cachedTopicPreview.is_post_voting), directRepliesByParent: new Map(), replyParentByPost: new Map(), tracker, totalPosts: 0, repliesIO: null, postRequestScheduler, subReplyState: new Map(), // 楼中楼原始数据 + 已渲染数量的状态表 confirmedReadPostNumbers: new Set(), streamNodeMap: new Map(), streamItems: [], streamItemMap: new Map(), streamPrefix: [0], onlyOp: false, streamLayoutItems: [], streamLayoutDirty: true, streamPrefixDirty: true, streamPrefixDirtyFrom: 0, streamNeedsRepair: true, streamPendingReindexFrom: null, streamWindowStart: 0, streamWindowEnd: 0, streamMountedItemCount: 0, streamViewportTopIndex: 0, streamViewportBottomIndex: 0, viewportPersistenceEnabled: false, streamScrollDirection: 0, streamScrollVelocity: 0, streamLastScrollAt: 0, streamProgrammaticTarget: null, streamViewportAnchor: null, streamViewportAnchorRestoreDepth: 0, streamViewportAnchorRestorePending: false, streamWindowResetPending: false, streamInternalScrollTop: NaN, streamInternalScrollDelta: 0, readerNavigationSequence: 0, streamListOffset: NaN, streamMountedNodes: new Set(), streamHydratedNodes: new Set(), pendingPostHydrations: new Set(), pendingPostLatexRenders: new Set(), streamTopSpacer: createStreamSpacer('top'), streamBottomSpacer: createStreamSpacer('bottom'), streamResizeObserver: null, streamWindowFrame: 0, floorPreviewSyncFrame: 0, latestReplyAt: '', latestReplyTimestamp: 0, topicBookmarked: !!(cachedTopicPreview && cachedTopicPreview.bookmarked), topicNotificationLevel: topicNotificationLevel(cachedTopicPreview), topicSharedIssueBusy: false, topicSharedIssueHostVisible: false, initialTargetPositionPending: targetPostNumber > 1, initialTargetHydrationBoundary: targetPostNumber > 1 ? targetPostNumber : 0, initialTargetPrecedingContentDeferred: targetPostNumber > 1, initialTargetHeadSpacerHeight: 0, cancelInitialTargetPosition: null, releaseInitialPrecedingContent: null, readerActionDialog: null, nativeComposerSession: null, toggleTopicBookmark: null, readerWindow, readerWorkspace, }; readerShell.activeContext = ctx; if (typeof readerShell.refreshBookmarksPanel !== 'function') { readerShell.refreshBookmarksPanel = (confirmedReaction = null) => { clearBookmarksSelectionScope(); const hadCompleteReactionSnapshot = bookmarksState.reactionLoadedAt > 0; const confirmedLike = confirmedReaction && typeof confirmedReaction.likeActed === 'boolean'; const confirmedCustomReaction = confirmedReaction && !confirmedLike && rememberConfirmedGivenReaction(confirmedReaction.post, confirmedReaction.topic); if (confirmedLike || confirmedCustomReaction) { if (confirmedCustomReaction) { bookmarksState.reactionEntries = mergeGivenReactionOverrides(bookmarksState.reactionEntries); } if (hadCompleteReactionSnapshot) { if (confirmedLike) { bookmarksState.reactionEntries = applyConfirmedGivenLike( bookmarksState.reactionEntries, confirmedReaction.post, confirmedReaction.topic, confirmedReaction.likeActed ); } bookmarksState.reactionLoadedAt = Date.now(); setGivenReactionSnapshot(bookmarksState.reactionUsername, bookmarksState.reactionEntries); } if (!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') { if (hadCompleteReactionSnapshot) { bookmarksState.page = 0; renderBookmarksPage(); } else loadBookmarksPage(); } return; } if (!bookmarksPanel.popover.hidden) loadBookmarksPage(); }; } ctx.refreshBookmarksPanel = (options) => readerShell.refreshBookmarksPanel(options); ctx.nativeComposerWindow = createNativeComposerWindowController(readerSession); ctx.renderTopicShell = renderTopicShell; ctx.repliesIO = createRepliesIO(ctx); ctx.streamResizeObserver = createStreamResizeObserver(ctx); const timeline = ctx.timeline = bindTopicTimeline(timelineEl, ctx); let topicEditorOptionsRequest = null; const loadTopicEditorOptions = () => { if (topicEditorOptionsRequest) return topicEditorOptionsRequest; topicEditorOptionsRequest = Promise.all([ fetchJSON(`${BASE}/site.json`, { key: 'topic-editor:categories', priority: POST_REQUEST_PRIORITY.auxiliary, }), fetchJSON(`${BASE}/tags.json`, { key: 'topic-editor:labels', priority: POST_REQUEST_PRIORITY.auxiliary, }), ]).then(([categoryPayload, labelPayload]) => ({ categories: Array.isArray(categoryPayload && categoryPayload.categories) ? categoryPayload.categories.filter((category) => category && +category.id > 0 && category.name) : [], labels: Array.isArray(labelPayload && labelPayload.tags) ? labelPayload.tags.filter((label) => label && label.pm_only !== true && (label.name || label.text)) : [], })).catch((error) => { topicEditorOptionsRequest = null; throw error; }); return topicEditorOptionsRequest; }; const openTopicEditor = () => { const topic = ctx.topicData; if (!topic || !topic.details || topic.details.can_edit !== true) return; if (topicEditorState) topicEditorState.close(true); const topicEditTrigger = overlay.querySelector('.ldp-topic-edit-trigger'); const layer = overlay.querySelector('.ldp-topic-edit-layer'); const form = overlay.querySelector('.ldp-topic-edit-form'); const titleInput = form.querySelector('input[name="title"]'); const categoryTrigger = form.querySelector('.ldp-topic-edit-category-trigger'); const categoryValue = form.querySelector('.ldp-topic-edit-category-value'); const categoryMenu = form.querySelector('.ldp-topic-edit-category-menu'); const categorySearch = form.querySelector('.ldp-topic-edit-category-search input'); const categoryOptions = form.querySelector('.ldp-topic-edit-category-options'); const labelInput = form.querySelector('.ldp-topic-edit-label-input'); const labelValues = form.querySelector('.ldp-topic-edit-label-values'); const labelOptions = form.querySelector('.ldp-topic-edit-label-options'); const saveButton = form.querySelector('.ldp-topic-edit-save'); const cancelButton = form.querySelector('.ldp-topic-edit-cancel'); const closeButton = layer.querySelector('.ldp-topic-edit-close'); const status = form.querySelector('.ldp-topic-edit-status'); const originalTitle = String(topic.title || '').trim(); const originalCategoryId = Number(topic.category_id || topic.categoryId) || 0; const originalLabelNames = Array.from(new Set( (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((name) => name.trim()).filter(Boolean), )); const labelsByKey = new Map(); const selectedLabelKeys = new Set(); const categoriesById = new Map(); let selectedCategoryId = originalCategoryId; let optionsLoaded = false; let busy = false; let closed = false; const topicEditorEventBindings = []; const labelKey = (name) => String(name || '').trim().toLocaleLowerCase(); originalLabelNames.forEach((name) => { const key = labelKey(name); labelsByKey.set(key, { id: null, name }); selectedLabelKeys.add(key); }); const selectedLabels = () => Array.from(selectedLabelKeys) .map((key) => labelsByKey.get(key)).filter(Boolean); const renderSelectedLabels = () => { labelValues.innerHTML = selectedLabels().map((label) => ` <span class="ldp-topic-edit-label-chip"> <span>${esc(label.name)}</span> <button class="ldp-topic-edit-label-remove" type="button" data-label-key="${escAttr(labelKey(label.name))}" aria-label="移除 label ${escAttr(label.name)}">${icon('x')}</button> </span>`).join(''); }; const categoryDisplayName = (category) => { if (!category) return '请选择类别'; const parent = categoriesById.get(Number(category.parent_category_id)); return parent ? `${parent.name} / ${category.name}` : String(category.name || '请选择类别'); }; const syncCategorySelection = () => { const selected = categoriesById.get(selectedCategoryId); categoryValue.textContent = categoryDisplayName(selected); categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => { option.setAttribute('aria-selected', +option.dataset.categoryId === selectedCategoryId ? 'true' : 'false'); }); }; const closeCategoryMenu = (restoreFocus = false) => { categoryMenu.hidden = true; categoryTrigger.setAttribute('aria-expanded', 'false'); if (restoreFocus && !categoryTrigger.disabled) categoryTrigger.focus(); }; const openCategoryMenu = () => { if (busy || !optionsLoaded || categoryTrigger.disabled) return; categoryMenu.hidden = false; categoryTrigger.setAttribute('aria-expanded', 'true'); categorySearch.value = ''; categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => { option.hidden = false; }); categorySearch.focus(); categoryOptions.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }; const renderCategoryOptions = () => { const fragment = document.createDocumentFragment(); categoriesById.forEach((category) => { const parent = categoriesById.get(Number(category.parent_category_id)); const option = document.createElement('button'); const color = String(category.color || '').replace(/^#/, ''); option.className = 'ldp-topic-edit-category-option ldp-picker-option'; option.type = 'button'; option.dataset.categoryId = String(category.id); option.dataset.searchText = `${parent ? `${parent.name} ` : ''}${category.name}`.toLocaleLowerCase(); option.setAttribute('role', 'option'); option.setAttribute('aria-selected', +category.id === selectedCategoryId ? 'true' : 'false'); option.innerHTML = ` <span class="ldp-topic-edit-category-dot"></span> <span class="ldp-topic-edit-category-copy ldp-picker-option-copy"> <strong>${esc(String(category.name || '未命名类别'))}</strong> ${parent ? `<small>${esc(String(parent.name))}</small>` : '<small>一级类别</small>'} </span>`; if (/^[0-9a-f]{6}$/i.test(color)) { option.querySelector('.ldp-topic-edit-category-dot').style.setProperty('--ldp-category-color', `#${color}`); } fragment.appendChild(option); }); categoryOptions.replaceChildren(fragment); syncCategorySelection(); }; const setBusy = (nextBusy) => { busy = nextBusy; titleInput.disabled = nextBusy; categoryTrigger.disabled = nextBusy || !optionsLoaded; categorySearch.disabled = nextBusy || !optionsLoaded; labelInput.disabled = nextBusy || !optionsLoaded; saveButton.disabled = nextBusy || !optionsLoaded; cancelButton.disabled = nextBusy; closeButton.disabled = nextBusy; if (nextBusy) closeCategoryMenu(); }; const commitLabelInput = (showError = true) => { const raw = String(labelInput.value || '').replace(/[,,]\s*$/, '').trim(); if (!raw) { labelInput.value = ''; return true; } const key = labelKey(raw); const label = labelsByKey.get(key); if (!label) { if (showError) status.textContent = `没有找到 label“${raw}”`; return false; } selectedLabelKeys.add(key); labelInput.value = ''; status.textContent = ''; renderSelectedLabels(); return true; }; const onLabelKeyDown = (event) => { if (event.key !== 'Enter' && event.key !== ',' && event.key !== ',') return; event.preventDefault(); commitLabelInput(); }; const onLabelChange = () => commitLabelInput(false); const onLabelRemove = (event) => { const button = event.target.closest('.ldp-topic-edit-label-remove'); if (!button || busy) return; selectedLabelKeys.delete(button.dataset.labelKey || ''); renderSelectedLabels(); labelInput.focus(); }; const onCategoryToggle = () => { if (categoryMenu.hidden) openCategoryMenu(); else closeCategoryMenu(true); }; const onCategoryTriggerKeyDown = (event) => { if (event.key !== 'ArrowDown') return; event.preventDefault(); openCategoryMenu(); }; const onCategoryOptionClick = (event) => { const option = event.target.closest('.ldp-topic-edit-category-option'); if (!option || busy) return; selectedCategoryId = Number(option.dataset.categoryId) || 0; syncCategorySelection(); closeCategoryMenu(true); }; const onCategorySearchInput = () => { const query = String(categorySearch.value || '').trim().toLocaleLowerCase(); categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => { option.hidden = !!query && !String(option.dataset.searchText || '').includes(query); }); }; const onCategoryMenuKeyDown = (event) => { handleSearchableMenuKeyDown(event, categorySearch, categoryOptions, '.ldp-topic-edit-category-option:not([hidden])', closeCategoryMenu); }; const onLayerClick = (event) => { if (event.target === layer) { close(); return; } if (!event.target.closest('.ldp-topic-edit-category')) closeCategoryMenu(); }; const close = (force = false) => { if (closed || (busy && !force)) return; closed = true; closeCategoryMenu(); layer.hidden = true; topicEditTrigger.setAttribute('aria-expanded', 'false'); removeTrackedEventListeners(topicEditorEventBindings); if (topicEditorState === state) topicEditorState = null; if (!force && !topicEditTrigger.hidden) topicEditTrigger.focus(); }; const onCancel = () => close(); const onSubmit = async (event) => { event.preventDefault(); if (busy || !optionsLoaded || !commitLabelInput()) return; const nextTitle = String(titleInput.value || '').trim(); const nextCategoryId = selectedCategoryId; if (!nextTitle) { status.textContent = '标题不能为空'; titleInput.focus(); return; } const category = categoriesById.get(nextCategoryId); if (!category) { status.textContent = '请选择类别'; categoryTrigger.focus(); return; } if (!ctx.topicData || !ctx.topicData.details || ctx.topicData.details.can_edit !== true) { status.textContent = '当前账号没有编辑该帖子的权限'; return; } setBusy(true); status.textContent = '正在保存…'; const nextLabels = selectedLabels(); const originalLabels = originalLabelNames.map((name) => labelsByKey.get(labelKey(name))).filter(Boolean); const params = { topic_id: topicId, title: nextTitle, original_title: originalTitle, category_id: nextCategoryId, tags: nextLabels.map((label) => Object.assign( { name: label.name }, +label.id > 0 ? { id: +label.id } : {}, )), }; if (originalLabels.length === originalLabelNames.length && originalLabels.every((label) => +label.id > 0)) { params.original_tags = originalLabels.map((label) => ({ id: label.id, name: label.name })); } try { const response = await apiSend(`${BASE}/t/${topicId}.json`, 'PUT', params, null, { jsonBody: true, invalidateTags: [`topic:${topicId}`], }); let freshTopic = null; try { freshTopic = await loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.visible }); } catch (refreshError) {} const updatedTopic = Object.assign({}, ctx.topicData || {}, response || {}, freshTopic || {}); updatedTopic.title = nextTitle; updatedTopic.fancy_title = String( (freshTopic && freshTopic.fancy_title) || (response && response.fancy_title) || nextTitle, ); updatedTopic.category_id = nextCategoryId; updatedTopic.category_name = cleanTopicCategoryName(category.name); updatedTopic.category_level = topicCategoryLevel(category.name); updatedTopic.category_slug = String(category.slug || ''); updatedTopic.tags = nextLabels.map((label) => label.name); topicNavMetadataOverride = { categoryName: updatedTopic.category_name, categoryLevel: updatedTopic.category_level, categorySlug: updatedTopic.category_slug, categoryId: nextCategoryId, categoryIconHtml: Number(sourceTopicNavMetadata.categoryId) === nextCategoryId ? sourceTopicNavMetadata.categoryIconHtml : '', tags: updatedTopic.tags.slice(), tagIcons: (Array.isArray(sourceTopicNavMetadata.tagIcons) ? sourceTopicNavMetadata.tagIcons : []) .filter((item) => updatedTopic.tags.includes(topicNavText(item && item.name))), }; sourceTopicNavMetadata = mergeTopicNavMetadata(topicNavMetadataOverride, cachedTopicNavMetadata(updatedTopic)); ctx.topicData = updatedTopic; setCachedTopicData(topicId, updatedTopic); rememberTopic(updatedTopic); busy = false; close(true); renderTopicShell(updatedTopic); syncTopicActionControls(ctx); showSelectionToast('帖子信息已更新'); } catch (error) { status.textContent = error && error.message ? error.message : '保存失败,请重试'; setBusy(false); } }; const state = { close, closeTransient: () => { if (categoryMenu.hidden) return false; closeCategoryMenu(true); return true; }, }; topicEditorState = state; titleInput.value = originalTitle; categoriesById.set(originalCategoryId, { id: originalCategoryId, name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'), slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''), }); categoryOptions.replaceChildren(); syncCategorySelection(); labelOptions.innerHTML = ''; labelInput.value = ''; status.textContent = '正在加载类别和 label…'; renderSelectedLabels(); setBusy(false); layer.hidden = false; topicEditTrigger.setAttribute('aria-expanded', 'true'); addTrackedEventListener(topicEditorEventBindings, labelInput, 'keydown', onLabelKeyDown); addTrackedEventListener(topicEditorEventBindings, labelInput, 'change', onLabelChange); addTrackedEventListener(topicEditorEventBindings, labelValues, 'click', onLabelRemove); addTrackedEventListener(topicEditorEventBindings, categoryTrigger, 'click', onCategoryToggle); addTrackedEventListener(topicEditorEventBindings, categoryTrigger, 'keydown', onCategoryTriggerKeyDown); addTrackedEventListener(topicEditorEventBindings, categoryOptions, 'click', onCategoryOptionClick); addTrackedEventListener(topicEditorEventBindings, categorySearch, 'input', onCategorySearchInput); addTrackedEventListener(topicEditorEventBindings, categoryMenu, 'keydown', onCategoryMenuKeyDown); addTrackedEventListener(topicEditorEventBindings, layer, 'click', onLayerClick); addTrackedEventListener(topicEditorEventBindings, form, 'submit', onSubmit); addTrackedEventListener(topicEditorEventBindings, cancelButton, 'click', onCancel); addTrackedEventListener(topicEditorEventBindings, closeButton, 'click', onCancel); titleInput.focus(); titleInput.select(); void loadTopicEditorOptions().then(({ categories, labels }) => { if (closed || topicEditorState !== state) return; labels.forEach((label) => { const name = String(label.name || label.text || '').trim(); if (name) labelsByKey.set(labelKey(name), { id: Number(label.id) || null, name }); }); originalLabelNames.forEach((name) => { const key = labelKey(name); if (!labelsByKey.has(key)) labelsByKey.set(key, { id: null, name }); }); labelOptions.innerHTML = Array.from(labelsByKey.values()) .map((label) => `<option value="${escAttr(label.name)}"></option>`).join(''); const loadedCategoryIds = new Set(categories.map((category) => +category.id)); const availableCategories = categories.slice(); if (originalCategoryId && !loadedCategoryIds.has(originalCategoryId)) { availableCategories.unshift({ id: originalCategoryId, name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'), slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''), }); } categoriesById.clear(); availableCategories.forEach((category) => categoriesById.set(+category.id, category)); renderCategoryOptions(); optionsLoaded = true; status.textContent = ''; renderSelectedLabels(); setBusy(false); }).catch((error) => { if (closed || topicEditorState !== state) return; status.textContent = `类别和 label 加载失败:${error && error.message ? error.message : '请重试'}`; }); }; overlay.querySelector('.ldp-topic-edit-trigger').addEventListener('click', openTopicEditor); overlay._ldpCaptureHistoryViewport = () => { timeline.sync(); const viewport = embeddedReaderViewportState(ctx) || {}; return normalizeReaderHistoryViewport({ postNumber: Number(viewport.postNumber) || Number(timelineEl.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')) || 1, postOffset: viewport.postOffset, scrollTop: Number.isFinite(Number(viewport.scrollTop)) ? Number(viewport.scrollTop) : body.scrollTop, }); }; overlay._ldpCaptureHistoryPostNumber = () => overlay._ldpCaptureHistoryViewport()?.postNumber || 1; const rememberCurrentTopicPosition = () => { const currentTopic = ctx.topicData; if (!currentTopic) return; const currentPostNumber = Number(overlay._ldpCaptureHistoryPostNumber?.()) || targetPostNumber || 1; rememberTopic(currentTopic, currentPostNumber); }; const onWindowResize = () => { closeFontFamilyMenu(); syncReaderBackgroundMode(); applyReaderVisualSettings(); syncOpenSettingsProfileControls(true); if (ctx.nativeComposerWindow) ctx.nativeComposerWindow.syncLayer(); positionHeaderPopovers(); ctx.streamListOffset = NaN; ctx.streamNeedsRepair = true; scheduleStreamWindowSync(ctx); }; window.addEventListener('resize', onWindowResize); overlay.addEventListener('ldp-reader-window-change', onWindowResize); let loading = false, done = false, pendingRetry = false, loadHalted = false, loadHaltError = null; let loadingPrev = false, loadingNext = false; let latestReplyPollTimer = 0, latestReplyPollRunning = false, latestReplyLastRefreshAt = 0; let latestReplyMessageBus = null, latestReplyMessageHandler = null, latestReplyEventTimer = 0; let latestReplyMessageChannels = []; const liveReactionRefreshTimers = new Map(); let pendingLiveReplyCount = 0, liveUpdateTargetPostNumber = 0, liveUpdateDismissed = false; let liveUpdateJumpRunning = false; let gapLoadTimer = 0, gapLoadDueAt = 0, pendingGapDirection = 0, pendingGapBatches = 1; let previousGapRetryCount = 0; let previousGapRetryAt = 0; let previousGapFailureUntil = 0, nextGapFailureUntil = 0; let onBodyScroll = null, onBodyTouchStart = null, onReaderScrollKeyDown = null; let onVisibilityChange = null; let scrollWorkFrame = 0, pendingGoingUp = false, filterPumpTimer = 0, onlyOpProgressTimer = 0; let onlyOpBatchStartedAt = 0, onlyOpRetryAt = 0, onlyOpRetryStreak = 0; let onlyOpLastProgressAt = 0, onlyOpLastScanned = 0; let destroyActions = null, stopTopicPresence = null, cancelInitialLatestRefresh = null, disposed = false, closePromise = null; let cancelInitialNeighborPrefetch = null; let failureRetryTimer = 0; let nativeAppEvents = null; let handleNativeComposerPostRefresh = null, nativeComposerRefreshTimer = 0; let nativeComposerHostIsolation = null, suspendedNativeComposerHostTopicBus = null; let onOnlyOpToggle = null, onTitleJump = null, onTitleJumpKeyDown = null, onTimelineTopClick = null, onOpenNativePost = null; let onReaderRefreshClick = null, onReaderCloseClick = null, onOverlayClick = null, onModalWheel = null; let onNativeComposerCloseClick = null, onNativeComposerSubmitClick = null, onNativeComposerSubmitKeyDown = null; const clearFailureRetryTimer = () => { if (failureRetryTimer) clearTimeout(failureRetryTimer); failureRetryTimer = 0; }; const stopOnlyOpProgress = () => { if (onlyOpProgressTimer) clearInterval(onlyOpProgressTimer); onlyOpProgressTimer = 0; }; const syncOnlyOpProgress = () => { if (!onlyOpProgress || !onlyOpProgressValue) return; if (!ctx.onlyOp) { stopOnlyOpProgress(); onlyOpProgress.hidden = true; return; } const total = Math.max(1, +(loader.streamLength || ctx.totalPosts || 1)); const scanned = Math.min(total, Math.max(0, +(loader.scanCursor || 0))); const skipped = Math.min(scanned, Math.max(0, +(loader.scanSkippedCount || 0))); const found = streamLayoutItems(ctx).length; const now = Date.now(); if (!onlyOpLastProgressAt || scanned !== onlyOpLastScanned) { onlyOpLastProgressAt = now; onlyOpLastScanned = scanned; if (scanned > 0) onlyOpRetryStreak = 0; } const percent = done ? 100 : Math.min(99.5, scanned / total * 100); const batchFlow = done ? '' : ` · 批${Math.max(1, Number(loader.scanBatchSize) || 1)}`; const skippedFlow = skipped ? ` · 缺失${skipped}` : ''; let requestState = null; try { requestState = postRequestScheduler.snapshot(); } catch (error) {} let state = done ? skipped ? 'partial' : 'done' : 'running'; let flow = ''; if (!done && loadHalted) { const status = Number(loadHaltError && loadHaltError.status || 0); state = 'error'; flow = ` · 已暂停${status ? ` HTTP ${status}` : ''}`; } else if (!done && requestState && requestState.coolingDown) { state = 'cooldown'; flow = ` · 429冷却 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))}秒`; } else if (!done && requestState && ['10s', '60s'].includes(requestState.blockingReason)) { state = 'window'; const label = requestState.blockingReason === '10s' ? '10秒窗' : '60秒窗'; flow = ` · ${label} ${Math.max(1, Math.ceil(requestState.nextPermitDelay / 1000))}秒`; } else if (!done && pendingRetry && onlyOpRetryAt > now) { state = 'retry'; flow = ` · 重试${onlyOpRetryStreak || 1} ${Math.max(1, Math.ceil((onlyOpRetryAt - now) / 1000))}秒`; } else if (!done && loading && onlyOpBatchStartedAt) { const waitingSeconds = Math.max(1, Math.ceil((now - onlyOpBatchStartedAt) / 1000)); const globallyBusy = requestState && ( Number(requestState.globalActive || 0) > 0 || Number(requestState.globalQueued || 0) > 0 ); state = waitingSeconds * 1000 >= POST_REQUEST_TIMEOUT ? 'waiting' : 'running'; flow = globallyBusy ? ` · 请求处理中 ${waitingSeconds}秒` : ` · 缓存协调中 ${waitingSeconds}秒`; } else if (!done && now - onlyOpLastProgressAt >= 2000) { state = 'waiting'; flow = ` · 调度恢复中 ${Math.max(2, Math.ceil((now - onlyOpLastProgressAt) / 1000))}秒`; } else if (!done && requestState) { const globalQueue = Math.max(requestState.queued, Number(requestState.globalQueued) || 0); flow = ` · 全局执行${requestState.globalActive} 排队${globalQueue}`; } onlyOpProgress.hidden = false; onlyOpProgress.dataset.state = state; onlyOpProgress.style.setProperty('--ldp-only-op-progress', `${percent.toFixed(1)}%`); const phase = done ? skipped ? '部分完成' : '完成' : state === 'error' ? '暂停' : state === 'retry' ? '等待' : '扫描'; onlyOpProgressValue.textContent = `${phase} ${scanned}/${total} · 楼主${found}${skippedFlow}${batchFlow}${flow}`; onlyOpProgress.setAttribute( 'aria-label', `只看楼主${done ? skipped ? '扫描结束但有缺失' : '扫描完成' : '压测中'},已处理 ${scanned}/${total} 层,找到 ${found} 条楼主内容${skipped ? `,其中 ${skipped} 层未能取回` : ''}${batchFlow}${flow}` ); onlyOpProgress.setAttribute('data-ldp-tooltip-label', onlyOpProgress.getAttribute('aria-label')); }; const startOnlyOpProgress = () => { stopOnlyOpProgress(); onlyOpLastProgressAt = Date.now(); onlyOpLastScanned = Math.max(0, +(loader.scanCursor || 0)); syncOnlyOpProgress(); if (ctx.onlyOp && !done) { onlyOpProgressTimer = setInterval(() => { syncOnlyOpProgress(); const retryDue = pendingRetry && onlyOpRetryAt > 0 && onlyOpRetryAt <= Date.now(); if (ctx.onlyOp && !done && !loading && !loadHalted && !filterPumpTimer && (!pendingRetry || retryDue) && Date.now() - onlyOpLastProgressAt >= 2000) { pump({ maxBatches: 1, forceOnlyOpScan: true }); } }, 250); } }; const prevLoadMisses = new Map(); const nextLoadMisses = new Map(); const gapMissBlocked = (misses, postNumber) => { const blockedUntil = Number(misses.get(+postNumber) || 0); if (!blockedUntil) return false; if (blockedUntil > Date.now()) return true; misses.delete(+postNumber); return false; }; const sessionAcceptsWork = () => readerSession.isCurrent('opening', 'active'); ctx.releaseInitialPrecedingContent = () => { if (!ctx.initialTargetPrecedingContentDeferred) return; ctx.initialTargetPrecedingContentDeferred = false; ctx.streamMountedNodes.forEach((node) => { if (+node.dataset.postNumber >= +(ctx.initialTargetHydrationBoundary || 0)) return; if (node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) { ctx.repliesIO.observe(node); } }); scheduleStreamWindowSync(ctx); }; const stopLatestReplyPolling = () => { if (latestReplyPollTimer) clearInterval(latestReplyPollTimer); if (latestReplyEventTimer) clearTimeout(latestReplyEventTimer); liveReactionRefreshTimers.forEach((timer) => clearTimeout(timer)); liveReactionRefreshTimers.clear(); latestReplyPollTimer = 0; latestReplyEventTimer = 0; if (latestReplyMessageBus && latestReplyMessageHandler) { latestReplyMessageChannels.forEach((channel) => { try { latestReplyMessageBus.unsubscribe(channel, latestReplyMessageHandler); } catch (e) {} }); } latestReplyMessageBus = null; latestReplyMessageHandler = null; latestReplyMessageChannels = []; }; const startLatestReplyPolling = () => { if (latestReplyPollTimer || latestReplyMessageHandler || !ctx.topicData || !sessionAcceptsWork() || document.visibilityState !== 'visible') return; const messageBus = lookupDiscourse('service:message-bus'); if (messageBus && typeof messageBus.subscribe === 'function' && typeof messageBus.unsubscribe === 'function') { const handler = (message) => { const payload = message && message.payload && typeof message.payload === 'object' ? message.payload : message; const messageTopicId = Number(message && (message.topic_id || payload && payload.topic_id) || 0); if (messageTopicId && messageTopicId !== +topicId) return; const reactionPostId = Number(payload && payload.post_id || 0); if (reactionPostId && Array.isArray(payload.reactions)) { if (liveReactionRefreshTimers.has(reactionPostId) || !sessionAcceptsWork() || document.visibilityState !== 'visible') return; const timer = setTimeout(async () => { liveReactionRefreshTimers.delete(reactionPostId); if (!sessionAcceptsWork() || typeof ctx.refreshPostById !== 'function') return; try { await ctx.refreshPostById(reactionPostId); } catch (error) { console.warn('[LINUX DO reader] live reaction refresh failed', error); } }, 100); liveReactionRefreshTimers.set(reactionPostId, timer); return; } if (latestReplyEventTimer || !sessionAcceptsWork() || document.visibilityState !== 'visible') return; latestReplyEventTimer = setTimeout(() => { latestReplyEventTimer = 0; refreshLatestReplyState(); }, 350); }; const channels = ['/latest', `/topic/${topicId}`, `/topic/${topicId}/reactions`]; try { channels.forEach((channel) => messageBus.subscribe(channel, handler)); latestReplyMessageBus = messageBus; latestReplyMessageHandler = handler; latestReplyMessageChannels = channels; return; } catch (e) { channels.forEach((channel) => { try { messageBus.unsubscribe(channel, handler); } catch (unsubscribeError) {} }); } } latestReplyPollTimer = setInterval(refreshLatestReplyState, 15000); }; overlay._ldpStopLatestReplyPolling = stopLatestReplyPolling; const syncLiveUpdateButton = () => { liveUpdateBtn.hidden = pendingLiveReplyCount <= 0 || liveUpdateDismissed; liveUpdateJumpBtn.disabled = liveUpdateJumpRunning; liveUpdateJumpBtn.setAttribute('aria-busy', String(liveUpdateJumpRunning)); const label = liveUpdateBtn.querySelector('span'); if (label) label.textContent = `有 ${pendingLiveReplyCount} 条新回复`; }; const clearLiveUpdateButton = () => { pendingLiveReplyCount = 0; liveUpdateTargetPostNumber = 0; liveUpdateDismissed = false; syncLiveUpdateButton(); }; const onLiveUpdateJump = async () => { if (!sessionAcceptsWork() || liveUpdateJumpRunning) return; const targetPostNumber = liveUpdateTargetPostNumber; const pendingCount = pendingLiveReplyCount; clearLiveUpdateButton(); if (!targetPostNumber) return; liveUpdateJumpRunning = true; syncLiveUpdateButton(); let jumped = false; try { jumped = await ctx.timeline.jumpTo(targetPostNumber); if (!jumped && sessionAcceptsWork()) { const targetItem = ctx.streamItemMap.get(targetPostNumber); if (ctx.onlyOp && !streamItemMatchesFilter(ctx, targetItem) && typeof onOnlyOpToggle === 'function') { await onOnlyOpToggle(); } else { ctx.streamWindowResetPending = true; syncStreamWindow(ctx); } await waitForReaderJumpLayout(); if (sessionAcceptsWork()) jumped = await ctx.timeline.jumpTo(targetPostNumber); } } catch { jumped = false; } finally { liveUpdateJumpRunning = false; if (!jumped && sessionAcceptsWork()) { pendingLiveReplyCount += pendingCount; if (!liveUpdateTargetPostNumber || targetPostNumber < liveUpdateTargetPostNumber) { liveUpdateTargetPostNumber = targetPostNumber; } liveUpdateDismissed = false; showSelectionToast(`暂时无法定位到楼层 #${targetPostNumber},可重试`); } syncLiveUpdateButton(); } }; const onLiveUpdateDismiss = (event) => { event.preventDefault(); event.stopPropagation(); liveUpdateDismissed = true; syncLiveUpdateButton(); }; liveUpdateJumpBtn.addEventListener('click', onLiveUpdateJump); liveUpdateDismissBtn.addEventListener('click', onLiveUpdateDismiss); const refreshLatestReplyState = async (options = {}) => { const force = options.force === true; if (latestReplyPollRunning || !sessionAcceptsWork() || document.visibilityState !== 'visible') return null; const now = Date.now(); if (!force && now - latestReplyLastRefreshAt < 10000) return null; latestReplyLastRefreshAt = now; latestReplyPollRunning = true; try { const refreshPriority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background; const previousHighestPostNumber = Math.max( 0, Number.isFinite(+options.afterPostNumber) ? +options.afterPostNumber : +(ctx.totalPosts || 0), ); const wasNearBottom = body.scrollHeight - body.scrollTop - body.clientHeight <= 120; const freshTopic = await loader.refreshTopicData({ priority: refreshPriority }); if (!freshTopic || !sessionAcceptsWork()) return null; const freshHighestPostNumber = Math.max(0, +(freshTopic.highest_post_number || 0), +(freshTopic.posts_count || 0)); const postsByNumber = new Map(); postsFromPayload(freshTopic).forEach((post) => { const postNumber = +(post && post.post_number || 0); if (!postNumber) return; if (ctx.streamNodeMap.has(postNumber) || postNumber > previousHighestPostNumber) { postsByNumber.set(postNumber, post); } }); let fetchAfter = previousHighestPostNumber; let pageGuard = 0; while (fetchAfter > 0 && fetchAfter < freshHighestPostNumber && pageGuard++ < 10) { const page = await loader.loadAfterPost(fetchAfter, { priority: refreshPriority, }); if (!sessionAcceptsWork()) return null; const newer = page.filter((post) => +(post && post.post_number || 0) > fetchAfter); if (!newer.length) break; newer.forEach((post) => postsByNumber.set(+post.post_number, post)); const nextAfter = newer.reduce((latest, post) => Math.max(latest, +(post.post_number || 0)), fetchAfter); if (nextAfter <= fetchAfter) break; fetchAfter = nextAfter; } const freshPosts = ingestLivePosts(Array.from(postsByNumber.values()), ctx); ctx.topicData = freshTopic; ctx.topicSharedIssueHostVisible = nativeTopicSharedIssueVisible(freshTopic); ctx.isPostVoting = !!freshTopic.is_post_voting; ctx.topicBookmarked = !!freshTopic.bookmarked; ctx.topicNotificationLevel = topicNotificationLevel(freshTopic); ctx.totalPosts = freshHighestPostNumber; rememberLatestReplyTime(ctx, { created_at: freshTopic.last_posted_at }, { force: true }); updateCommentsHeader(ctx); syncTopicActionControls(ctx); renderTopicShell(freshTopic); syncAllPostActionControls(ctx); ctx.timeline.update(); if (freshPosts.length) { if (!liveUpdateTargetPostNumber) { liveUpdateTargetPostNumber = +(freshPosts[0]?.post_number || 0); } if (wasNearBottom) { clearLiveUpdateButton(); const previousScrollTop = body.scrollTop; body.scrollTop = body.scrollHeight; markStreamInternalScroll(ctx, previousScrollTop); } else { liveUpdateDismissed = false; pendingLiveReplyCount += freshPosts.length; syncLiveUpdateButton(); } } return { posts: Array.from(postsByNumber.values()), topic: freshTopic, }; } catch (e) { // Keep the last known timestamp when a background refresh fails. return null; } finally { latestReplyPollRunning = false; } }; ctx.refreshPostByNumber = async (postNumber) => { const posts = await loader.loadPost(+postNumber, { priority: POST_REQUEST_PRIORITY.target, forceRefresh: true, }); if (!sessionAcceptsWork()) return null; loader.persistFetchedPosts(posts); ingestLivePosts(posts, ctx, { refreshContent: true }); return posts.find((post) => +(post && post.post_number || 0) === +postNumber) || posts[0] || null; }; ctx.refreshPostById = async (postId) => { const targetPostId = Number(postId) || 0; if (!targetPostId) return null; const post = await fetchJSON(`${BASE}/posts/${targetPostId}.json`, { scheduler: postRequestScheduler, priority: POST_REQUEST_PRIORITY.visible, cacheMode: 'refresh', allowStaleOnError: false, fetchOptions: { cache: 'no-store' }, key: `post:${targetPostId}:live-reaction`, }); if (!sessionAcceptsWork() || !post || +post.id !== targetPostId || !post.post_number) return null; loader.persistFetchedPosts([post]); ingestLivePosts([post], ctx, { refreshContent: true }); return post; }; ctx.refreshNativeComposerSession = async (session) => { if (!session || !sessionAcceptsWork()) return false; if (session.action === 'edit') { const updated = await ctx.refreshPostByNumber(session.postNumber); if (!updated) return false; const target = readerPostCopies(updated.id, ctx)[0]; if (target) keepJumpHighlightUntilSettled(target); return true; } if (session.action !== 'reply') return false; const parentPostNumber = +(session.parentPostNumber || 0); const highestPostNumber = +(session.highestPostNumber || 0); const retryDelays = [0, 700, 1400]; for (let attempt = 0; attempt < retryDelays.length && sessionAcceptsWork(); attempt++) { if (retryDelays[attempt]) { await waitForDelay(retryDelays[attempt]); if (!sessionAcceptsWork()) return false; } const result = await refreshLatestReplyState({ force: true, afterPostNumber: highestPostNumber, priority: POST_REQUEST_PRIORITY.visible, }); const candidates = new Map(); (result && result.posts || []).forEach((post) => candidates.set(+post.post_number, post)); ctx.streamItems.forEach((item) => { const post = item && item.node && item.node._ldpPostData; if (post && +post.post_number > highestPostNumber) candidates.set(+post.post_number, post); }); const created = Array.from(candidates.values()) .filter((post) => +post.post_number > highestPostNumber) .filter((post) => !ME_USERNAME || post.username === ME_USERNAME) .filter((post) => parentPostNumber <= 1 ? +(post.reply_to_post_number || 0) <= 1 : +(post.reply_to_post_number || 0) === parentPostNumber) .sort((a, b) => +b.post_number - +a.post_number)[0]; if (created) { if (ctx.loader && typeof ctx.loader.persistFetchedPosts === 'function') { ctx.loader.persistFetchedPosts([created]); } clearLiveUpdateButton(); return focusLiveNestedReply(created, ctx); } } return false; }; ctx.bindNativeComposerRefresh = () => { if (handleNativeComposerPostRefresh) return true; nativeAppEvents = lookupDiscourse('service:app-events'); if (!nativeAppEvents || typeof nativeAppEvents.on !== 'function') return false; handleNativeComposerPostRefresh = () => { const session = ctx.nativeComposerSession; if (!session || session.refreshing || !sessionAcceptsWork()) return; session.refreshing = true; nativeComposerRefreshTimer = setTimeout(async () => { nativeComposerRefreshTimer = 0; try { await ctx.refreshNativeComposerSession(session); } finally { if (ctx.nativeComposerSession === session) ctx.nativeComposerSession = null; } }, 0); }; nativeAppEvents.on('composer:created-post', handleNativeComposerPostRefresh); nativeAppEvents.on('post-stream:refresh', handleNativeComposerPostRefresh); return true; }; ctx.bindNativeComposerRefresh(); onVisibilityChange = () => { if (!sessionAcceptsWork()) return; const visible = document.visibilityState === 'visible'; ctx.timeline.setActive(visible); recordResourceMonitorVisibility(); if (!visible) { stopLatestReplyPolling(); stopRequestFlowUpdates(); suspendReaderMedia(overlay); ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node)); return; } ctx.streamMountedNodes.forEach((node) => activateReaderMedia(node)); startLatestReplyPolling(); if (!settingsPopover.hidden && !resourceMonitorSection.hidden) startResourceMonitorUpdates(); if (!settingsPopover.hidden && !requestFlowSection.hidden) startRequestFlowUpdates(); ctx.timeline.update(); ctx.timeline.refreshRelativeTimes(); refreshLatestReplyState(); }; document.addEventListener('visibilitychange', onVisibilityChange); const readerEscapeExitWindow = 1500; let readerEscapeExitDeadline = 0; let readerEscapeExitTimer = 0; let readerEscapePointerX = Number.NaN; let readerEscapePointerY = Number.NaN; const disarmReaderEscapeExit = () => { readerEscapeExitDeadline = 0; if (readerEscapeExitTimer) clearTimeout(readerEscapeExitTimer); readerEscapeExitTimer = 0; readerPortalQuery('.ldp-selection-toast[data-ldp-reader-exit-hint]')?.remove(); }; const armReaderEscapeExit = () => { disarmReaderEscapeExit(); readerEscapeExitDeadline = Date.now() + readerEscapeExitWindow; showSelectionToast('再按一次 Esc 退出阅读器'); const toast = readerPortalQuery('.ldp-selection-toast'); if (toast) toast.dataset.ldpReaderExitHint = '1'; readerEscapeExitTimer = setTimeout(disarmReaderEscapeExit, readerEscapeExitWindow); }; const nativeComposerCloseWindow = 1500; let nativeComposerCloseAction = ''; let nativeComposerCloseDeadline = 0; let nativeComposerCloseTimer = 0; const disarmNativeComposerClose = () => { nativeComposerCloseAction = ''; nativeComposerCloseDeadline = 0; if (nativeComposerCloseTimer) clearTimeout(nativeComposerCloseTimer); nativeComposerCloseTimer = 0; readerPortalQuery('.ldp-selection-toast[data-ldp-composer-close-hint]')?.remove(); }; const nativeComposerCloseConfirmed = (action) => nativeComposerCloseAction === action && nativeComposerCloseDeadline > Date.now(); const armNativeComposerClose = (action, message) => { disarmNativeComposerClose(); nativeComposerCloseAction = action; nativeComposerCloseDeadline = Date.now() + nativeComposerCloseWindow; showSelectionToast(message); const toast = readerPortalQuery('.ldp-selection-toast'); if (toast) toast.dataset.ldpComposerCloseHint = '1'; nativeComposerCloseTimer = setTimeout(disarmNativeComposerClose, nativeComposerCloseWindow); }; const releaseReaderNativeComposerHostIsolation = (isolation = nativeComposerHostIsolation) => { if (!isolation || isolation.released) return; isolation.released = true; if (isolation.appEvents && isolation.appEvents.trigger === isolation.trigger) { try { isolation.appEvents.trigger = isolation.originalTrigger; } catch (error) {} } if (nativeComposerHostIsolation === isolation) nativeComposerHostIsolation = null; }; const resumeReaderNativeComposerHostTopicBus = () => { const suspended = suspendedNativeComposerHostTopicBus; suspendedNativeComposerHostTopicBus = null; if (!suspended || !suspended.topicController || typeof suspended.topicController.subscribe !== 'function') return; const currentModel = discourseModelValue(suspended.topicController, 'model'); const currentTopicId = +(discourseModelValue(currentModel, 'id') || 0); if (currentTopicId !== suspended.hostTopicId) return; try { suspended.topicController.subscribe(); } catch (error) {} }; const isolateReaderNativeComposerFromHost = () => { const session = ctx.nativeComposerSession; if (!readerWorkspace.isEmbedded() || !session || session.action !== 'reply') return null; releaseReaderNativeComposerHostIsolation(); const appEvents = session.composer && ( session.composer.appEvents || lookupDiscourse('service:app-events') ); if (!appEvents || typeof appEvents.trigger !== 'function') return null; const originalTrigger = appEvents.trigger; const suppressedEvents = new Set([ 'post:created', 'post-stream:posted', 'post-stream:refresh', 'post:highlight', ]); const isolation = { appEvents, originalTrigger, trigger: null, released: false, }; isolation.trigger = function (eventName, ...args) { if (suppressedEvents.has(String(eventName || ''))) return this; return originalTrigger.call(this, eventName, ...args); }; try { appEvents.trigger = isolation.trigger; } catch (error) { return null; } const topicController = lookupDiscourse('controller:topic'); const hostModel = discourseModelValue(topicController, 'model'); const hostTopicId = +(discourseModelValue(hostModel, 'id') || 0); const alreadySuspended = suspendedNativeComposerHostTopicBus && suspendedNativeComposerHostTopicBus.topicController === topicController && suspendedNativeComposerHostTopicBus.hostTopicId === hostTopicId; if (!alreadySuspended && hostTopicId === +ctx.topicId && topicController && typeof topicController.unsubscribe === 'function') { resumeReaderNativeComposerHostTopicBus(); try { topicController.unsubscribe(); suspendedNativeComposerHostTopicBus = { topicController, hostTopicId }; } catch (error) {} } nativeComposerHostIsolation = isolation; return isolation; }; const nativeReaderReplyComposerButton = () => { const session = ctx.nativeComposerSession; if (!session || session.action !== 'reply' || discourseModelValue(session.composer, 'model') !== session.model) return null; const composer = document.querySelector('#reply-control'); if (!nativeComposerVisible(composer)) return null; return composer.querySelector('.composer-action-reply .save-or-cancel button.create'); }; const submitReaderNativeReplyWithoutHostRefresh = (button) => { const session = ctx.nativeComposerSession; const composer = session && session.composer; if (!button || button.disabled || !composer || typeof composer.save !== 'function') return false; const isolation = isolateReaderNativeComposerFromHost(); try { const saveResult = composer.save(true, { jump: false }); Promise.resolve(saveResult).then( () => releaseReaderNativeComposerHostIsolation(isolation), () => releaseReaderNativeComposerHostIsolation(isolation), ); } catch (error) { releaseReaderNativeComposerHostIsolation(isolation); console.warn('[LINUX DO reader] native composer reply submit failed', error); } return true; }; onNativeComposerSubmitClick = (event) => { const button = event.target.closest?.('#reply-control .composer-action-reply .save-or-cancel button.create'); if (!button || button !== nativeReaderReplyComposerButton()) return; event.preventDefault(); event.stopImmediatePropagation(); submitReaderNativeReplyWithoutHostRefresh(button); }; onNativeComposerSubmitKeyDown = (event) => { if (event.isComposing || !(event.ctrlKey || event.metaKey) || (event.key !== 'Enter' && event.code !== 'Enter')) return; const button = nativeReaderReplyComposerButton(); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); submitReaderNativeReplyWithoutHostRefresh(button); }; onNativeComposerCloseClick = (event) => { const button = event.target.closest?.('#reply-control .toggle-save-and-close, #reply-control .discard-button'); if (!button || !nativeComposerVisible(document.querySelector('#reply-control'))) return; const action = button.classList.contains('discard-button') ? 'discard' : 'save-close'; if (nativeComposerCloseConfirmed(action)) { disarmNativeComposerClose(); if (action === 'discard') { event.preventDefault(); event.stopImmediatePropagation(); discardReaderNativeComposer(ctx).catch((error) => { console.warn('[LINUX DO reader] native composer discard failed', error); }); } return; } event.preventDefault(); event.stopImmediatePropagation(); armNativeComposerClose(action, action === 'discard' ? '再点一次舍弃回复' : '再点一次关闭回复窗口'); }; const onReaderEscapePointerMove = (event) => { readerEscapePointerX = event.clientX; readerEscapePointerY = event.clientY; }; const visibleExpandedDefaultPosts = () => { const rootRect = ctx.scrollRoot.getBoundingClientRect(); return Array.from(ctx.commentsEl.querySelectorAll( ':scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)', )).map((post) => ({ post, rect: post.getBoundingClientRect() })) .filter(({ post }) => isEscCollapsibleNestedReply(post)) .filter(({ rect }) => rect.bottom > rootRect.top && rect.top < rootRect.bottom && rect.right > rootRect.left && rect.left < rootRect.right) .sort((a, b) => a.rect.top - b.rect.top); }; const pickExpandedDefaultPost = () => { if (ctx.onlyOp) return null; const candidates = visibleExpandedDefaultPosts(); const pointed = candidates.find(({ rect }) => Number.isFinite(readerEscapePointerX) && Number.isFinite(readerEscapePointerY) && readerEscapePointerX >= rect.left && readerEscapePointerX <= rect.right && readerEscapePointerY >= rect.top && readerEscapePointerY <= rect.bottom); return (pointed || candidates[0] || {}).post || null; }; const onReaderEscapeExitInteraction = (event) => { disarmReaderEscapeExit(); if (event.type !== 'pointerdown' || event.button !== 0 || !readerWorkspace.isEmbedded() || eventPathIncludes(event, READER_PORTAL_ROOT)) return; closeReaderFloatingPanels(); }; let runtimeStopped = false; let shellStopped = false; const runReaderCleanup = (label, cleanup) => { try { cleanup(); } catch (error) { console.warn(`[LDP] ${label} cleanup failed`, error); } }; const stopReaderRuntime = (options = {}) => { const preserveShell = options.preserveShell === true; if (!runtimeStopped) { runtimeStopped = true; if (typeof overlay._ldpCloseCodePreview === 'function') overlay._ldpCloseCodePreview(false, true); if (preserveShell) { const parkedCommentsHeader = parkReaderCommentsHeader(overlay, ctx.commentsHeader); if (parkedCommentsHeader) readerShell.commentsHeader = parkedCommentsHeader; } stopLatestReplyPolling(); stopOnlyOpProgress(); stopRateLimitNotice(); latestReplyPollRunning = false; runReaderCleanup('topic nav metadata', stopTopicNavMetadataHydration); if (nativeComposerRefreshTimer) clearTimeout(nativeComposerRefreshTimer); nativeComposerRefreshTimer = 0; releaseReaderNativeComposerHostIsolation(); resumeReaderNativeComposerHostTopicBus(); if (nativeAppEvents && handleNativeComposerPostRefresh && typeof nativeAppEvents.off === 'function') { nativeAppEvents.off('composer:created-post', handleNativeComposerPostRefresh); nativeAppEvents.off('post-stream:refresh', handleNativeComposerPostRefresh); } [ ['loader', () => loader.destroy()], ['request scheduler', () => { if (ACTIVE_READER_REQUEST_SCHEDULER === postRequestScheduler) ACTIVE_READER_REQUEST_SCHEDULER = null; postRequestScheduler.destroy(); }], ['user card', hideFallbackUserCard], ['action dialog', () => ctx.readerActionDialog && ctx.readerActionDialog.close()], ['topic editor', () => topicEditorState && topicEditorState.close(true)], ['composer window', () => ctx.nativeComposerWindow.destroy()], ['post actions', () => destroyActions && destroyActions()], ['read tracker', () => tracker.stop()], ['nested replies', () => ctx.repliesIO.disconnect()], ['media players', () => suspendReaderMedia(overlay)], ['detached media players', () => ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node))], ['pending content hydration', () => Array.from(ctx.pendingPostHydrations) .forEach((node) => cancelPostContentHydration(node, ctx))], ['pending latex render', () => Array.from(ctx.pendingPostLatexRenders) .forEach((node) => cancelPostLatexRender(node, ctx))], ['jump highlight', clearActiveJumpHighlight], ['stream viewport anchor', () => clearStreamViewportAnchor(ctx)], ['avatar placeholders', () => releasePersistentAvatarImages(overlay)], ['resize observer', () => ctx.streamResizeObserver.disconnect()], ['timeline', () => ctx.timeline.destroy()], ['topic presence', () => stopTopicPresence && stopTopicPresence()], ].forEach(([label, cleanup]) => runReaderCleanup(label, cleanup)); if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame); if (ctx.floorPreviewSyncFrame) cancelAnimationFrame(ctx.floorPreviewSyncFrame); if (scrollWorkFrame) cancelAnimationFrame(scrollWorkFrame); ctx.streamWindowFrame = 0; ctx.floorPreviewSyncFrame = 0; scrollWorkFrame = 0; [cancelInitialLatestRefresh, cancelInitialNeighborPrefetch].forEach((cancel) => { if (cancel) cancel(); }); [gapLoadTimer, filterPumpTimer].forEach((timer) => { if (timer) clearTimeout(timer); }); gapLoadTimer = 0; filterPumpTimer = 0; gapLoadDueAt = 0; pendingGapDirection = 0; pendingGapBatches = 1; const nativeOpen = onOpenNativePost && overlay.querySelector('.ldp-open'); removeTrackedEventListeners([ [body, 'scroll', onBodyScroll], [body, 'touchstart', onBodyTouchStart], [overlay, 'keydown', onReaderScrollKeyDown], [onlyOpBtn, 'click', onOnlyOpToggle], [overlay.querySelector('.ldp-title-jump'), 'click', onTitleJump], [overlay.querySelector('.ldp-title-jump'), 'keydown', onTitleJumpKeyDown], [overlay.querySelector('.ldp-topic-edit-trigger'), 'click', openTopicEditor], [timelineEl.querySelector('.ldp-topic-timeline-top'), 'click', onTimelineTopClick], [nativeOpen, 'click', onOpenNativePost], [nativeOpen, 'auxclick', onOpenNativePost], [liveUpdateJumpBtn, 'click', onLiveUpdateJump], [liveUpdateDismissBtn, 'click', onLiveUpdateDismiss], [overlay.querySelector('.ldp-reader-refresh'), 'click', onReaderRefreshClick], [overlay.querySelector('.ldp-close'), 'click', onReaderCloseClick], [overlay, 'pointermove', onReaderSwitchPointerMove], [overlay, 'click', onOverlayClick], [modal, 'wheel', onModalWheel], [document, 'click', onNativeComposerSubmitClick, true], [document, 'keydown', onNativeComposerSubmitKeyDown, true], [document, 'click', onNativeComposerCloseClick, true], ].filter(([target, , listener]) => target && listener)); disarmNativeComposerClose(); disarmReaderEscapeExit(); removeTrackedEventListeners([ [overlay, 'pointermove', onReaderEscapePointerMove], [document, 'pointerdown', onReaderEscapeExitInteraction, true], [document, 'scroll', onReaderEscapeExitInteraction, true], [document, 'keydown', onEsc, true], [document, 'visibilitychange', onVisibilityChange], [window, 'resize', onWindowResize], [overlay, 'ldp-reader-window-change', onWindowResize], ].filter(([target, , listener]) => target && listener)); if (readerShell.activeContext === ctx) readerShell.activeContext = null; [ctx.streamItems, ctx.streamLayoutItems].forEach((items) => { items.length = 0; }); [ctx.streamItemMap, ctx.streamMountedNodes, ctx.streamHydratedNodes, ctx.pendingPostHydrations, ctx.pendingPostLatexRenders, ctx.streamNodeMap, ctx.subReplyState, ctx.directRepliesByParent, ctx.confirmedReadPostNumbers, ctx.replyParentByPost].forEach((collection) => collection.clear()); } if (!preserveShell && !shellStopped) { shellStopped = true; overlay.dataset.readerShellDestroyed = '1'; runReaderCleanup('avatar observers', () => { releasePersistentAvatarImages(notificationPanel.list); releasePersistentAvatarImages(historyPanel.list); releasePersistentAvatarImages(bookmarksPanel.list); }); runReaderCleanup('resource monitor view', stopResourceMonitorUpdates); runReaderCleanup('request flow view', stopRequestFlowUpdates); if (readerShell.cancelShellFrames) readerShell.cancelShellFrames(); runReaderCleanup('reader window', () => readerShell.readerWindow.destroy()); runReaderCleanup('reader workspace', () => readerShell.readerWorkspace.destroy()); if (readerShell.onReaderWorkspaceChange) { overlay.removeEventListener('ldp-reader-workspace-change', readerShell.onReaderWorkspaceChange); } } }; let readerSwitchTarget = null; let readerSwitchFrame = 0; let readerSwitchX = 0; let readerSwitchY = 0; const popupCanSwitchTopic = () => !readerWindow.isPinned() && !readerWorkspace.isEmbedded() && !readerUsesFullPage() && window.innerWidth > READER_WINDOW_COMPACT_WIDTH; const readerTopicTargetAt = (clientX, clientY) => { const overlayDisplay = overlay.style.getPropertyValue('display'); const overlayDisplayPriority = overlay.style.getPropertyPriority('display'); let source; try { overlay.style.setProperty('display', 'none', 'important'); source = document.elementFromPoint(clientX, clientY); } finally { if (overlayDisplay) overlay.style.setProperty('display', overlayDisplay, overlayDisplayPriority); else overlay.style.removeProperty('display'); } const link = source && source.closest('a[href]'); const route = link && !link.classList.contains('ldp-open') && !shouldBypassReaderUrl(link.href) ? extractTopicRouteFromUrl(link.getAttribute('href') || link.href) : null; return route ? { link, route } : null; }; const setReaderSwitchTarget = (link) => { if (readerSwitchTarget === link) return; readerSwitchTarget?.classList.remove('ldp-reader-switch-target'); readerSwitchTarget = link || null; readerSwitchTarget?.classList.add('ldp-reader-switch-target'); overlay.classList.toggle('ldp-reader-switch-ready', !!readerSwitchTarget); }; const stopReaderSwitchTarget = () => { if (readerSwitchFrame) cancelAnimationFrame(readerSwitchFrame); readerSwitchFrame = 0; setReaderSwitchTarget(null); }; const syncReaderSwitchTarget = () => { readerSwitchFrame = 0; const target = popupCanSwitchTopic() ? readerTopicTargetAt(readerSwitchX, readerSwitchY) : null; setReaderSwitchTarget(target && target.link); }; const onReaderSwitchPointerMove = (event) => { if (event.target !== overlay || event.pointerType === 'touch' || !popupCanSwitchTopic()) { stopReaderSwitchTarget(); return; } readerSwitchX = event.clientX; readerSwitchY = event.clientY; if (!readerSwitchFrame) readerSwitchFrame = requestAnimationFrame(syncReaderSwitchTarget); }; let prepareTopicSwitchPromise = null; const saveReaderNativeComposerDraftSafely = () => { let draftSavePromise; try { draftSavePromise = saveAndCloseReaderNativeComposer(ctx); } catch (error) { console.warn('[LINUX DO reader] native composer draft save setup failed', error); draftSavePromise = Promise.resolve(false); } return Promise.resolve(draftSavePromise).catch((error) => { console.warn('[LINUX DO reader] native composer draft save cleanup failed', error); return false; }); }; const prepareTopicSwitch = () => { if (prepareTopicSwitchPromise) return prepareTopicSwitchPromise; if (disposed) return Promise.resolve(false); clearFailureRetryTimer(); stopReaderSwitchTarget(); readerSession.state = 'switching'; const draftSavePromise = saveReaderNativeComposerDraftSafely(); rememberCurrentTopicPosition(); stopReaderRuntime({ preserveShell: true }); disposeHistoryNavigation(); overlay._ldpCaptureHistoryViewport = () => null; overlay._ldpCaptureHistoryPostNumber = () => 1; prepareTopicSwitchPromise = draftSavePromise; return prepareTopicSwitchPromise; }; const close = (closeOptions = {}) => { if (closePromise) return closePromise; if (disposed) return Promise.resolve(); clearFailureRetryTimer(); stopReaderSwitchTarget(); disposed = true; readerSession.state = 'closing'; const draftSavePromise = saveReaderNativeComposerDraftSafely(); rememberCurrentTopicPosition(); stopReaderRuntime(); disposeHistoryNavigation(); removeOwnedNode(overlay); if (CURRENT_OVERLAY === overlay) CURRENT_OVERLAY = null; queueMicrotask(releaseReaderPortalIfIdle); if (ACTIVE_READER_SESSION_ID === readerSessionId) ACTIVE_READER_SESSION_ID = 0; document.documentElement.classList.remove('ldp-reader-open'); setPageScrollLocked(false); readerSession.state = 'closed'; if (!closeOptions.preserveDocumentTitle) restoreHostDocumentTitle(); if (!closeOptions.suppressNavigation) { AUTO_OPEN_SUPPRESSED_TOPIC_ID = String(topicId); document.documentElement.classList.remove('ldp-route-takeover'); syncNativeReaderTrigger(); } closePromise = draftSavePromise; return closePromise; }; overlay._ldpDispose = () => close({ suppressNavigation: true, preserveDocumentTitle: true }); overlay._ldpPrepareTopicSwitch = prepareTopicSwitch; function onEsc(e) { if (e.key !== 'Escape') { disarmReaderEscapeExit(); return; } if (typeof overlay._ldpCloseCodePreview === 'function') { e.preventDefault(); e.stopImmediatePropagation(); disarmReaderEscapeExit(); overlay._ldpCloseCodePreview(); return; } if (e.repeat) { e.preventDefault(); e.stopImmediatePropagation(); return; } const exitConfirmed = readerEscapeExitDeadline > Date.now(); disarmReaderEscapeExit(); const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu'); const boostEmojiPicker = Array.from(document.querySelectorAll('.emoji-picker')).find(nativeComposerVisible); if (boostMenu && boostEmojiPicker && typeof boostMenu._ldpCloseEmojiPicker === 'function') { e.preventDefault(); e.stopImmediatePropagation(); boostMenu._ldpCloseEmojiPicker(); return; } const nativeFloatingSurface = visibleNativeFloatingSurface(); if (nativeFloatingSurface) return; if (boostMenu && typeof boostMenu._ldpClose === 'function') { e.preventDefault(); e.stopImmediatePropagation(); boostMenu._ldpClose(); return; } const nativeComposer = document.querySelector('#reply-control'); if (nativeComposerVisible(nativeComposer)) { e.preventDefault(); e.stopImmediatePropagation(); if (nativeComposerCloseConfirmed('escape')) { disarmNativeComposerClose(); discardReaderNativeComposer(ctx).catch((error) => { console.warn('[LINUX DO reader] native composer discard failed', error); }); } else { armNativeComposerClose('escape', '再按一次 Esc 舍弃回复'); } return; } if (topicEditorState) { e.preventDefault(); e.stopPropagation(); if (!topicEditorState.closeTransient || !topicEditorState.closeTransient()) topicEditorState.close(); return; } if (ctx.readerActionDialog) { e.preventDefault(); e.stopPropagation(); ctx.readerActionDialog.close(); return; } if (typeof ctx.closeTransientActions === 'function' && ctx.closeTransientActions()) { e.preventDefault(); e.stopPropagation(); return; } if (fallbackUserCardOpen()) { e.preventDefault(); e.stopPropagation(); if (typeof ctx.closeUserCardLayer === 'function') ctx.closeUserCardLayer(); else if (typeof ctx.closeUserCard === 'function') ctx.closeUserCard(); else hideFallbackUserCard(); return; } if (ctx.floorPreview && ctx.floorPreview.isOpen() && ctx.floorPreview.isVisibleInViewport()) { e.preventDefault(); e.stopPropagation(); ctx.floorPreview.hide(); if (pickExpandedDefaultPost()) { showSelectionToast('再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下'); } return; } const collapsePost = pickExpandedDefaultPost(); if (collapsePost) { e.preventDefault(); e.stopPropagation(); setNestedReplyCollapsed(collapsePost, true); const nextPost = pickExpandedDefaultPost(); showSelectionToast(nextPost ? `已收起 #${collapsePost.dataset.postNumber || ''} · Esc 继续收起 #${nextPost.dataset.postNumber || ''}` : `已收起楼层 #${collapsePost.dataset.postNumber || ''}`); return; } const visibleSurface = [ [notificationsPopover, closeNotifications], [historyPanel.popover, closeHistory], [bookmarksPanel.popover, closeBookmarks], [fontFamilyMenu, () => closeFontFamilyMenu(true)], [settingsPopover, requestCloseSettings], ].find(([surface]) => !surface.hidden); if (visibleSurface) { e.preventDefault(); e.stopPropagation(); visibleSurface[1](); return; } if (readerPortalQuery('.ldp-lightbox')) return; e.preventDefault(); e.stopImmediatePropagation(); if (exitConfirmed) close(); else armReaderEscapeExit(); } const readerRefreshBtn = overlay.querySelector('.ldp-reader-refresh'); readerRefreshBtn.disabled = readerSession.state !== 'active'; onReaderRefreshClick = async () => { if (readerRefreshBtn.disabled || readerSession.state !== 'active') return; readerRefreshBtn.disabled = true; readerRefreshBtn.classList.add('is-refreshing'); try { const viewport = embeddedReaderViewportState(ctx) || {}; const refreshPostNumber = Math.max( 1, Number(viewport.postNumber) || Number(overlay._ldpCaptureHistoryPostNumber?.()) || 1, ); const refreshOnlyOp = ctx.onlyOp === true; const topicCacheSnapshot = ctx.topicData; if (typeof overlay._ldpPrepareTopicSwitch === 'function') { await overlay._ldpPrepareTopicSwitch(); } await clearCurrentTopicCaches(topicId, topicCacheSnapshot); await openModal(topicId, { targetPostNumber: refreshPostNumber, restoreOnlyOp: refreshOnlyOp, restoreViewport: { postNumber: refreshPostNumber, postOffset: Number(viewport.postOffset) || 0, scrollTop: Number(viewport.scrollTop) || 0, }, topicNavMetadata: sourceTopicNavMetadata, historyNavigation, }); } finally { readerRefreshBtn.classList.remove('is-refreshing'); if (readerRefreshBtn.isConnected && readerSession.state === 'active') readerRefreshBtn.disabled = false; } }; readerRefreshBtn.addEventListener('click', onReaderRefreshClick); onReaderCloseClick = () => close(); overlay.querySelector('.ldp-close').addEventListener('click', onReaderCloseClick); overlay.addEventListener('pointermove', onReaderSwitchPointerMove); onOverlayClick = (e) => { if (e.target !== overlay || !popupCanSwitchTopic()) return; const target = readerTopicTargetAt(e.clientX, e.clientY); if (target) { stopReaderSwitchTarget(); AUTO_OPEN_SUPPRESSED_TOPIC_ID = ''; hideNativeReaderTrigger(); openModal(target.route.topicId, { targetPostNumber: topicUrlReaderStartPostNumber(target.route), topicNavMetadata: readTopicNavMetadata(target.link), }); return; } close(); }; overlay.addEventListener('click', onOverlayClick); onModalWheel = (e) => { const floatingSurface = e.target.closest?.('.ldp-reaction-picker,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)'); if (floatingSurface) { containFloatingSurfaceWheel(floatingSurface, e); return; } if (notificationsPopover.contains(e.target) || historyPanel.popover.contains(e.target) || bookmarksPanel.popover.contains(e.target)) return; const topicEditLayer = e.target.closest?.('.ldp-topic-edit-layer'); if (topicEditLayer) { e.preventDefault(); const categoryMenu = e.target.closest('.ldp-topic-edit-category-menu'); const categoryOptions = categoryMenu?.querySelector('.ldp-topic-edit-category-options'); if (categoryOptions && !categoryMenu.hidden) { const categoryDelta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaMode === 2 ? e.deltaY * categoryOptions.clientHeight : e.deltaY; categoryOptions.scrollTop += categoryDelta; } return; } if (ctx.timeline && ctx.timeline.releaseJump) ctx.timeline.releaseJump(); const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaMode === 2 ? e.deltaY * body.clientHeight : e.deltaY; if (delta < 0) { ctx.streamScrollDirection = -1; ctx.streamLastScrollAt = Date.now(); ctx.streamScrollVelocity = Math.max(+(ctx.streamScrollVelocity || 0), Math.abs(delta) / 16); captureStreamViewportAnchor(ctx); clearInitialTargetHeadSpacer(ctx); ctx.releaseInitialPrecedingContent(); scheduleStreamWindowSync(ctx); scheduleGapLoad(true); } else if (delta > 0) { ctx.streamScrollDirection = 1; captureStreamViewportAnchor(ctx); } if (body.contains(e.target)) return; e.preventDefault(); body.scrollTop += delta; }; modal.addEventListener('wheel', onModalWheel, { passive: false }); document.addEventListener('pointerdown', onReaderEscapeExitInteraction, true); document.addEventListener('scroll', onReaderEscapeExitInteraction, true); document.addEventListener('click', onNativeComposerSubmitClick, true); document.addEventListener('keydown', onNativeComposerSubmitKeyDown, true); document.addEventListener('click', onNativeComposerCloseClick, true); document.addEventListener('keydown', onEsc, true); overlay.addEventListener('pointermove', onReaderEscapePointerMove); const sentinelVisible = () => body.scrollTop + body.clientHeight >= body.scrollHeight - 300; const pump = async (options = {}) => { if (loading || loadHalted || !sessionAcceptsWork()) return; loading = true; const maxBatches = options.maxBatches ?? 1; const keepFilling = options.keepFilling === true; const pumpOnlyOp = ctx.onlyOp; const forceOnlyOpScan = options.forceOnlyOpScan === true && pumpOnlyOp; if (pumpOnlyOp) onlyOpBatchStartedAt = Date.now(); let batches = 0; let immediateRetries = 0; let retryDelay = 180; // 只要还没加载完,进入循环前先亮出底部提示 if (!done) { endTip.classList.remove('show'); loadingTip.classList.add('show'); } try { while (!done && batches < maxBatches && (forceOnlyOpScan || sentinelVisible() || pendingRetry || (keepFilling && batches > 0))) { pendingRetry = false; const { posts, done: isDone, retry, fatal, error } = await loader.next({ onlyOp: pumpOnlyOp, onlyOpUsername: pumpOnlyOp ? ctx.op : '', persistFetchedPosts: options.persistFetchedPosts === true, onSource: (source, counts) => syncLoadingState(source, counts), }); if (!sessionAcceptsWork()) return; if (posts.length) syncLoadingState('render'); attachPostBatch(posts, ctx); if (ctx.onlyOp) syncOnlyOpProgress(); if (fatal) { loadHalted = true; loadHaltError = error || null; pendingRetry = false; break; } if (retry) { pendingRetry = true; loadHaltError = error || null; const status = Number(error && error.status || 0); const endpointRetryDelay = Math.max(0, Number(error && error.retryAt || 0) - Date.now()); if (pumpOnlyOp) onlyOpRetryStreak++; retryDelay = status === 429 || error && error.name === 'AbortError' ? Math.max(250, endpointRetryDelay) : status === 408 || status >= 500 ? 1000 : 400; if (shouldStopReaderFallback(error) || immediateRetries >= 1) break; immediateRetries++; await waitForDelay(400); continue; } loadHaltError = null; onlyOpRetryStreak = 0; onlyOpRetryAt = 0; retryDelay = 180; batches++; if (ctx.onlyOp === pumpOnlyOp) done = isDone; } } catch (error) { if (sessionAcceptsWork()) { loadHaltError = error || new Error('请求流异常停止'); const transient = !!error && ( error.name === 'AbortError' || error.name === 'TypeError' || Number(error.status || 0) === 408 || Number(error.status || 0) === 429 || Number(error.status || 0) >= 500 ); if (transient) { pendingRetry = true; if (pumpOnlyOp) onlyOpRetryStreak++; const endpointRetryDelay = Math.max(0, Number(error && error.retryAt || 0) - Date.now()); retryDelay = Math.min( endpointRetryDelay > 0 ? READER_ENDPOINT_429_MAX_BLOCK_MS : 15000, Math.max( endpointRetryDelay, 1000 * Math.pow(2, Math.min(4, Math.max(0, onlyOpRetryStreak - 1))) ) ); } else { loadHalted = pumpOnlyOp; pendingRetry = !pumpOnlyOp; retryDelay = 1000; console.warn( `[LINUX DO reader] request pump ${pumpOnlyOp ? 'stopped' : 'will retry'} after unexpected error`, error ); } } } finally { loading = false; if (pumpOnlyOp) onlyOpBatchStartedAt = 0; if (sessionAcceptsWork()) { // 本轮抓取结束(无论成功、失败或无更多内容)都收起提示 loadingTip.classList.remove('show'); endTip.classList.toggle('show', done); if (ctx.onlyOp) syncOnlyOpProgress(); if ((ctx.onlyOp || pendingRetry) && !done && !loadHalted && !filterPumpTimer) { if (ctx.onlyOp) onlyOpRetryAt = Date.now() + retryDelay; filterPumpTimer = setTimeout(() => { filterPumpTimer = 0; onlyOpRetryAt = 0; if (sessionAcceptsWork()) { pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp }); } }, retryDelay); } else if (done || loadHalted || !ctx.onlyOp) { onlyOpRetryAt = 0; } } } }; const hasStreamGap = (node, backward) => { if (ctx.isPostVoting) return false; const num = +(node && node.dataset.postNumber); const misses = backward ? prevLoadMisses : nextLoadMisses; if (!num || (backward && num <= 1) || gapMissBlocked(misses, num)) return false; const item = ctx.streamItemMap.get(num); const lastPostNumber = Math.max(1, ctx.totalPosts || 1); if (ctx.onlyOp) { const layoutItems = streamLayoutItems(ctx); const boundary = layoutItems[backward ? 0 : layoutItems.length - 1]; const loaded = ctx.streamItems[backward ? 0 : ctx.streamItems.length - 1]; return item && item === boundary && loaded && (backward ? loaded.postNumber > 1 : loaded.postNumber < lastPostNumber); } const adjacentIndex = item ? item.index + (backward ? -1 : 1) : -1; const adjacent = adjacentIndex >= 0 && adjacentIndex < ctx.streamItems.length ? ctx.streamItems[adjacentIndex] : null; if (!adjacent) return backward || num < lastPostNumber; return backward ? num - adjacent.postNumber > 1 : adjacent.postNumber - num > 1; }; const visibleGapPost = (backward) => { const items = streamLayoutItems(ctx); const lookahead = scrollLoadPressure(ctx).lookahead; const start = Math.max(ctx.streamWindowStart, ctx.streamViewportTopIndex - (backward ? lookahead : 1)); const end = Math.min(ctx.streamWindowEnd, ctx.streamViewportBottomIndex + (backward ? 2 : lookahead + 1)); for (let index = start; index < end; index++) { const node = items[index] && items[index].node; if (!node || !node.isConnected || !hasStreamGap(node, backward)) continue; return node; } return null; }; const loadStreamGap = async (backward, preferredAnchor = null, options = {}) => { const loadingGap = backward ? loadingPrev : loadingNext; const failureUntil = backward ? previousGapFailureUntil : nextGapFailureUntil; if (loadingGap || !sessionAcceptsWork() || Date.now() < failureUntil) return false; const hasGap = (node) => hasStreamGap(node, backward); const findAnchor = () => visibleGapPost(backward); let anchor = preferredAnchor && (!backward || preferredAnchor.isConnected) && hasGap(preferredAnchor) ? preferredAnchor : findAnchor(); if (backward && !anchor && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1)) { ctx.streamNeedsRepair = true; syncStreamWindow(ctx); anchor = findAnchor(); } if (!anchor) return false; if (backward) captureStreamViewportAnchor(ctx); const maxBatches = Math.max(1, Math.min(2, +(options.maxBatches || 1))); let anchorNum = +anchor.dataset.postNumber; let loadedCount = 0; let retryDelay = 0; if (backward) loadingPrev = true; else loadingNext = true; try { for (let batch = 0; batch < maxBatches; batch++) { const directionChanged = backward ? ctx.streamScrollDirection > 0 : ctx.streamScrollDirection < 0; if (batch > 0 && (directionChanged || scrollLoadPressure(ctx).batches < 2)) break; const posts = await loader[backward ? 'loadBeforePost' : 'loadAfterPost'](anchorNum, { persistFetchedPosts: options.persistFetchedPosts === true, }); if (backward) previousGapFailureUntil = 0; else nextGapFailureUntil = 0; if (!sessionAcceptsWork()) return false; if (backward && ctx.streamScrollDirection >= 0) return false; const fresh = posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number)); if (!fresh.length) { (backward ? prevLoadMisses : nextLoadMisses).set(anchorNum, Date.now() + 5000); if (backward) { retryDelay = 5100; previousGapRetryAt = Date.now() + retryDelay; } break; } const sorted = fresh.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0)); if (backward) { ctx.initialTargetHeadSpacerHeight = 0; attachPostBatch(sorted, ctx, { syncStreamNow: true, preserveViewportAnchor: true, }); restoreStreamViewportAnchor(ctx); previousGapRetryCount = 0; previousGapRetryAt = 0; } else attachPostBatch(sorted, ctx); loadedCount += sorted.length; const edgePost = sorted[backward ? 0 : sorted.length - 1]; const nextAnchorNum = +(edgePost && edgePost.post_number || 0); const nextAnchor = ctx.streamNodeMap.get(nextAnchorNum); const advanced = backward ? nextAnchorNum < anchorNum : nextAnchorNum > anchorNum; if (!nextAnchorNum || !advanced || !nextAnchor || !hasGap(nextAnchor)) break; anchorNum = nextAnchorNum; } return loadedCount > 0; } catch (e) { if (backward) { previousGapFailureUntil = Date.now() + 3000; retryDelay = 3100; previousGapRetryAt = Date.now() + retryDelay; } else nextGapFailureUntil = Date.now() + 3000; } finally { if (backward) loadingPrev = false; else loadingNext = false; if (backward && retryDelay && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1) && previousGapRetryCount < 2) { previousGapRetryCount++; scheduleGapLoad(true, retryDelay); } } return false; }; const loadPreviousGap = (preferredAnchor = null, options = {}) => loadStreamGap(true, preferredAnchor, options); const loadNextGap = (preferredAnchor = null, options = {}) => loadStreamGap(false, preferredAnchor, options); const scheduleGapLoad = (goingUp, minimumDelay = 0) => { const direction = goingUp ? -1 : 1; const pressure = scrollLoadPressure(ctx); const directionChanged = !!pendingGapDirection && pendingGapDirection !== direction; pendingGapDirection = direction; pendingGapBatches = Math.max(directionChanged ? 1 : pendingGapBatches, pressure.batches); const previousRetryDelay = direction < 0 ? Math.max(0, previousGapRetryAt - Date.now()) : 0; const dueAt = Date.now() + Math.max(previousRetryDelay, Math.max(0, +minimumDelay || 0)); if (gapLoadTimer && !directionChanged && dueAt >= gapLoadDueAt) return; if (gapLoadTimer) clearTimeout(gapLoadTimer); gapLoadDueAt = dueAt; gapLoadTimer = setTimeout(() => { gapLoadTimer = 0; gapLoadDueAt = 0; if (!sessionAcceptsWork()) return; const nextDirection = pendingGapDirection; const maxBatches = pendingGapBatches; pendingGapDirection = 0; pendingGapBatches = 1; if (nextDirection < 0) loadPreviousGap(null, { maxBatches }); else if (nextDirection > 0) { if (loading || sentinelVisible()) return; loadNextGap(null, { maxBatches }); } }, Math.max(0, dueAt - Date.now())); }; ctx.loadPost = async (postNumber, options = {}) => { if (!sessionAcceptsWork()) return false; postNumber = +postNumber; if (!postNumber) return false; const around = options.scope === 'around'; const shouldMount = options.mount !== false; const ensureAround = options.ensureAround === true && postNumber > 1; const targetAlreadyKnown = ctx.streamNodeMap.has(postNumber); if (targetAlreadyKnown && (!around || !ensureAround)) { return shouldMount ? !!getStreamPostNode(ctx, postNumber, { mount: true }) : true; } let posts = []; try { posts = await loader.loadPost(postNumber, { onSource: options.onSource, priority: Number.isFinite(options.priority) ? options.priority : around ? POST_REQUEST_PRIORITY.target : undefined, scope: around ? 'around' : 'single', ...(around ? {} : { forceRefresh: options.forceRefresh === true }), }); } catch (error) { if (!around || !targetAlreadyKnown) throw error; } if (!sessionAcceptsWork()) return false; const fresh = around ? posts.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0)) : posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number)); attachPostBatch(fresh, ctx); return shouldMount ? !!getStreamPostNode(ctx, postNumber, { mount: true }) : ctx.streamNodeMap.has(postNumber); }; const nearestAvailablePost = (postNumber) => { const target = +postNumber || 0; if (!target) return null; return loader.cachedPosts.reduce((nearest, candidate) => { const current = +(candidate && candidate.post_number || 0); if (!current) return nearest; if (!nearest) return candidate; const nearestNumber = +(nearest.post_number || 0); const currentDistance = Math.abs(current - target); const nearestDistance = Math.abs(nearestNumber - target); if (currentDistance < nearestDistance) return candidate; if (currentDistance === nearestDistance && current > target && nearestNumber < target) return candidate; return nearest; }, null); }; const syncOnlyOpButton = () => { onlyOpBtn.classList.toggle('active', ctx.onlyOp); onlyOpBtn.setAttribute('aria-pressed', String(ctx.onlyOp)); onlyOpBtn.setAttribute('aria-label', ctx.onlyOp ? '显示全部楼层' : '只看楼主'); }; onOnlyOpToggle = async () => { if (!sessionAcceptsWork() || onlyOpBtn.disabled || !ctx.op) return; clearStreamViewportAnchor(ctx); ctx.onlyOp = !ctx.onlyOp; pendingRetry = false; done = ctx.onlyOp ? loader.scanDone : loader.loadDone; if (!ctx.onlyOp && filterPumpTimer) { clearTimeout(filterPumpTimer); filterPumpTimer = 0; } if (!ctx.onlyOp) { onlyOpBatchStartedAt = 0; onlyOpRetryAt = 0; onlyOpRetryStreak = 0; } syncOnlyOpButton(); invalidateStreamLayout(ctx); if (ctx.onlyOp) startOnlyOpProgress(); else syncOnlyOpProgress(); ctx.streamWindowResetPending = true; ctx.streamMountedItemCount = -1; const previousScrollTop = body.scrollTop; body.scrollTop = 0; markStreamInternalScroll(ctx, previousScrollTop); if (ctx.onlyOp && !ctx.streamItemMap.has(1)) { await ctx.loadPost(1, { priority: POST_REQUEST_PRIORITY.target }); if (!sessionAcceptsWork()) return; invalidateStreamLayout(ctx); } flushStreamWindow(ctx); ctx.timeline.update(); if (ctx.onlyOp) pump({ maxBatches: 1, forceOnlyOpScan: true }); }; onlyOpBtn.addEventListener('click', onOnlyOpToggle); try { setReaderOpenPhase('load-topic'); const topic = await initialTopicRequest; if (!sessionAcceptsWork()) return; setReaderOpenPhase('initialize-topic'); ctx.topicData = topic; ctx.topicSharedIssueHostVisible = !loader.initializedFromCache && nativeTopicSharedIssueVisible(topic); postsFromPayload(topic).forEach((post) => rememberPostData(ctx, post)); ctx.isPostVoting = !!topic.is_post_voting; ctx.topicBookmarked = !!topic.bookmarked; ctx.topicNotificationLevel = topicNotificationLevel(topic); ctx.latestReplyAt = String(topic.last_posted_at || ''); ctx.latestReplyTimestamp = Date.parse(ctx.latestReplyAt) || 0; ctx.op = topic._opUsername; onlyOpBtn.disabled = !ctx.op; syncOnlyOpButton(); ctx.totalPosts = Math.max(0, topic.highest_post_number || 0, topic.posts_count || 0); renderTopicShell(topic); updateCommentsHeader(ctx); syncTopicActionControls(ctx); ensureMe().then(() => { if (sessionAcceptsWork()) { syncTopicActionControls(ctx); syncAllPostActionControls(ctx); overlay.querySelectorAll('.ldp-post').forEach((postNode) => { renderPostSpecialContent(postNode, ctx); if (postNode.querySelector(':scope > .ldp-boost-list:not(.ldp-boost-pending)')) { syncRenderedBoost(postNode, postNode._ldpPostData || {}, ctx); } }); } }); if (sessionAcceptsWork()) { if (loader.initializedFromCache) { cancelInitialLatestRefresh = scheduleIdleWork(() => { cancelInitialLatestRefresh = null; refreshLatestReplyState(); }, 1400); } startLatestReplyPolling(); } const titleBtn = overlay.querySelector('.ldp-title-jump'); let cancelInitialTargetJump = false; ctx.cancelInitialTargetPosition = () => { cancelInitialTargetJump = true; ctx.initialTargetPositionPending = false; clearInitialTargetHeadSpacer(ctx); ctx.releaseInitialPrecedingContent(); }; onTitleJump = async () => { if (!sessionAcceptsWork()) return; await ctx.timeline.jumpToStart(); }; titleBtn.addEventListener('click', onTitleJump); onTitleJumpKeyDown = (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); titleBtn.click(); }; titleBtn.addEventListener('keydown', onTitleJumpKeyDown); onTimelineTopClick = () => titleBtn.click(); timelineEl.querySelector('.ldp-topic-timeline-top').addEventListener('click', onTimelineTopClick); const openBtn = overlay.querySelector('.ldp-open'); openBtn.href = `${BASE}/t/${topic.id}?${NATIVE_BYPASS_PARAM}=1`; openBtn.hidden = false; openBtn.setAttribute('aria-label', '打开原帖'); onOpenNativePost = (event) => { if (event.type === 'click' && event.button && event.button !== 0) return; if (event.type === 'auxclick' && event.button !== 1) return; if (!openNativeTopicTab(openBtn.href)) return; event.preventDefault(); event.stopPropagation(); }; openBtn.addEventListener('click', onOpenNativePost); openBtn.addEventListener('auxclick', onOpenNativePost); ctx.toggleTopicBookmark = bindTopicBookmark(topic, ctx); stopTopicPresence = bindReaderTopicPresence(ctx); destroyActions = bindActions(modal, ctx); tracker.start(); let lastScrollTop = body.scrollTop; let lastScrollAt = Date.now(); ctx.streamInternalScrollTop = NaN; ctx.streamInternalScrollDelta = 0; onBodyScroll = () => { if (!sessionAcceptsWork()) return; const now = Date.now(); const nextScrollTop = body.scrollTop; const rawDelta = nextScrollTop - lastScrollTop; const elapsed = Math.max(16, now - lastScrollAt); lastScrollTop = nextScrollTop; lastScrollAt = now; const expectedInternalScrollTop = ctx.streamInternalScrollTop; const internalScrollDelta = Number.isFinite(ctx.streamInternalScrollDelta) ? ctx.streamInternalScrollDelta : 0; ctx.streamInternalScrollTop = NaN; ctx.streamInternalScrollDelta = 0; // Anchor compensation and a wheel step can share one scroll event. // Remove only the compensation so windowing still sees the user's residual movement. let delta = rawDelta - internalScrollDelta; if (Math.abs(delta) < 1) delta = 0; const hasInternalScroll = Math.abs(internalScrollDelta) >= 0.01 || (Number.isFinite(expectedInternalScrollTop) && Math.abs(nextScrollTop - expectedInternalScrollTop) < 1); if (hasInternalScroll && !delta) { scheduleStreamWindowSync(ctx); return; } if (ctx.initialTargetPositionPending) { scheduleStreamWindowSync(ctx); return; } if (delta && readerSession.state === 'active') ctx.viewportPersistenceEnabled = true; const deltaDirection = delta < 0 ? -1 : delta > 0 ? 1 : 0; if (deltaDirection) { ctx.streamScrollDirection = deltaDirection; captureStreamViewportAnchor(ctx); if (deltaDirection < 0) { clearInitialTargetHeadSpacer(ctx); ctx.releaseInitialPrecedingContent(); } else { previousGapRetryCount = 0; } } const followsUserIntent = !deltaDirection || deltaDirection === ctx.streamScrollDirection; pendingGoingUp = ctx.streamScrollDirection < 0; if (followsUserIntent) { const instantVelocity = Math.abs(delta) / elapsed; ctx.streamScrollVelocity = ctx.streamScrollVelocity * 0.55 + instantVelocity * 0.45; } ctx.streamLastScrollAt = now; if (pendingLiveReplyCount > 0 && body.scrollHeight - nextScrollTop - body.clientHeight <= 80) { clearLiveUpdateButton(); } scheduleStreamWindowSync(ctx); if (scrollWorkFrame) return; scrollWorkFrame = requestAnimationFrame(() => { scrollWorkFrame = 0; if (!sessionAcceptsWork()) return; if (body.scrollTop <= 10 && !ctx.streamItemMap.has(1)) { pendingGapDirection = 0; pendingGapBatches = 1; if (gapLoadTimer) { clearTimeout(gapLoadTimer); gapLoadTimer = 0; gapLoadDueAt = 0; } loadPreviousGap(null, { maxBatches: scrollLoadPressure(ctx).batches, }); } else { scheduleGapLoad(pendingGoingUp); } if (sentinelVisible()) { const pressure = scrollLoadPressure(ctx); pump({ maxBatches: pressure.batches, keepFilling: pressure.batches > 1, }); } }); }; body.addEventListener('scroll', onBodyScroll, { passive: true }); onBodyTouchStart = () => { if (ctx.timeline && ctx.timeline.releaseJump) ctx.timeline.releaseJump(); ctx.streamScrollDirection = 0; captureStreamViewportAnchor(ctx); ctx.releaseInitialPrecedingContent(); }; body.addEventListener('touchstart', onBodyTouchStart, { passive: true }); onReaderScrollKeyDown = (event) => { if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) { if (ctx.timeline && ctx.timeline.releaseJump) ctx.timeline.releaseJump(); ctx.streamScrollDirection = -1; captureStreamViewportAnchor(ctx); clearInitialTargetHeadSpacer(ctx); ctx.releaseInitialPrecedingContent(); scheduleGapLoad(true); } else if (['ArrowDown', 'PageDown', 'End'].includes(event.key) || (event.key === ' ' && !event.shiftKey)) { if (ctx.timeline && ctx.timeline.releaseJump) ctx.timeline.releaseJump(); ctx.streamScrollDirection = 1; captureStreamViewportAnchor(ctx); } }; overlay.addEventListener('keydown', onReaderScrollKeyDown); setReaderOpenPhase('load-target-post'); let targetLoaded = false; const syncTargetLoadingSource = (source) => syncLoadingState(source, { target: true }); targetLoaded = await ctx.loadPost(Math.max(1, targetPostNumber), { priority: POST_REQUEST_PRIORITY.target, onSource: syncTargetLoadingSource, }); if (targetPostNumber > 1 && !targetLoaded) { targetLoaded = await ctx.loadPost(targetPostNumber, { onSource: syncTargetLoadingSource, scope: 'around', }); } if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) { targetLoaded = await ctx.loadPost(targetPostNumber, { priority: POST_REQUEST_PRIORITY.target, forceRefresh: true, onSource: syncTargetLoadingSource, }); } if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) { const fallbackPost = nearestAvailablePost(targetPostNumber); const fallbackPostNumber = +(fallbackPost && fallbackPost.post_number || 0); if (fallbackPostNumber) { unavailableTargetPostNumber = targetPostNumber; attachPostBatch([fallbackPost], ctx); targetPostNumber = fallbackPostNumber; targetLoaded = !!getStreamPostNode(ctx, targetPostNumber, { mount: true }); overlay.dataset.targetPostNumber = String(targetPostNumber); readerShell.targetPostNumber = targetPostNumber; ctx.initialTargetHydrationBoundary = targetPostNumber; if (restoreViewport && +restoreViewport.postNumber === unavailableTargetPostNumber) { restoreViewport = { ...restoreViewport, postNumber: targetPostNumber, postOffset: 0 }; } } if (!targetLoaded) { throw new Error(`目标楼层 #${targetPostNumber} 加载失败,请重试`); } } if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) { setReaderOpenPhase('load-target-neighborhood'); await ctx.loadPost(targetPostNumber, { ensureAround: true, priority: POST_REQUEST_PRIORITY.visible, onSource: syncTargetLoadingSource, scope: 'around', }); if (!sessionAcceptsWork()) return; } if (targetPostNumber <= 1 && !targetLoaded) { await pump({ maxBatches: 1, persistFetchedPosts: true }); } // The batch path normally mounts on the next animation frame. Flush the // first window before removing the mask so opening never exposes a blank frame. setReaderOpenPhase('mount-initial-stream'); syncLoadingState('render'); if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) { ctx.releaseInitialPrecedingContent(); hydrateStreamNavigationNeighborhood(ctx, targetPostNumber); } flushStreamWindow(ctx); let initialQuoteVisualTarget = null; const resolveInitialQuoteVisualTarget = initialQuoteHighlight && targetPostNumber ? () => { initialQuoteVisualTarget = applyReaderQuoteHighlight( targetPostNumber, ctx, initialQuoteHighlight.text, { source: initialQuoteHighlight.source }, ); return initialQuoteVisualTarget; } : null; const initialStartPostNumber = Math.max(1, +targetPostNumber || 1); if (targetPostNumber > 1) { if (targetLoaded && !cancelInitialTargetJump) { const alignInitialTargetBottom = !initialQuoteHighlight && +targetPostNumber === +(ctx.totalPosts || 0); const initialTargetScrollOptions = { ...PINNED_STREAM_SCROLL_OPTIONS, resolveVisualTarget: resolveInitialQuoteVisualTarget, alignNearest: !!initialQuoteHighlight, alignBottom: alignInitialTargetBottom, alignTop: !alignInitialTargetBottom, }; if (!scrollToPost(targetPostNumber, ctx, initialTargetScrollOptions)) { throw new Error(`目标楼层 #${targetPostNumber} 定位失败,请重试`); } captureStreamViewportAnchor(ctx); } overlay.classList.remove('ldp-initial-target-layout'); ctx.initialTargetPositionPending = false; } else { ctx.initialTargetPositionPending = false; if (!scrollToPost(initialStartPostNumber, ctx, { ...PINNED_STREAM_SCROLL_OPTIONS, resolveVisualTarget: resolveInitialQuoteVisualTarget, alignNearest: !!initialQuoteHighlight, alignTop: true, highlight: !!initialQuoteHighlight, })) { throw new Error(`目标楼层 #${initialStartPostNumber} 定位失败,请重试`); } } if (targetLoaded && (targetPostNumber > 1 || ctx.totalPosts > initialStartPostNumber)) { cancelInitialNeighborPrefetch = scheduleIdleWork(async () => { cancelInitialNeighborPrefetch = null; if (!sessionAcceptsWork()) return; const startNode = ctx.streamNodeMap.get(initialStartPostNumber); if (!startNode || !startNode.isConnected) return; await loadNextGap(startNode); }, 1400); } if (!sessionAcceptsWork()) return; if (restoreOnlyOp && !ctx.onlyOp && ctx.op) await onOnlyOpToggle(); if (!sessionAcceptsWork()) return; if (restoreViewport) { const restoredPostNumber = Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1); const restoredPost = getStreamPostNode(ctx, restoredPostNumber, { mount: true }); const restoredPostOffset = stableReaderViewportOffset(restoredPost, ctx, restoreViewport.postOffset); const restored = !!restoredPost && scrollToPost(restoredPostNumber, ctx, { ...PINNED_STREAM_SCROLL_OPTIONS, visualNode: restoredPost, alignOffset: restoredPostOffset, highlight: false, }); if (!restored) { throw new Error(`历史位置 #${restoredPostNumber} 恢复失败,请重试`); } flushStreamWindow(ctx); captureStreamViewportAnchor(ctx); } if (!sessionAcceptsWork()) return; if (settleTargetJump) { await waitForReaderInitialLayout(overlay); if (!sessionAcceptsWork()) return; ctx.timeline.sync(); const settledTargetPostNumber = restoreViewport ? Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1) : targetPostNumber; let settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected ? initialQuoteVisualTarget : getStreamPostNode(ctx, settledTargetPostNumber); if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) { const retryPost = getStreamPostNode(ctx, settledTargetPostNumber, { mount: true }); const retryVisualTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected ? initialQuoteVisualTarget : retryPost; const retryRestoreOffset = restoreViewport ? stableReaderViewportOffset(retryVisualTarget, ctx, restoreViewport.postOffset) : NaN; const retryAlignBottom = !restoreViewport && !initialQuoteHighlight && +settledTargetPostNumber === +(ctx.totalPosts || 0); if (retryPost && scrollToPost(settledTargetPostNumber, ctx, { ...PINNED_STREAM_SCROLL_OPTIONS, visualNode: retryVisualTarget, resolveVisualTarget: !restoreViewport ? resolveInitialQuoteVisualTarget : null, alignOffset: retryRestoreOffset, alignNearest: !restoreViewport && !!initialQuoteHighlight, alignBottom: retryAlignBottom, alignTop: !restoreViewport && !initialQuoteHighlight && !retryAlignBottom, highlight: false, })) { captureStreamViewportAnchor(ctx); await waitForReaderJumpLayout(); if (!sessionAcceptsWork()) return; ctx.timeline.sync(); settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected ? initialQuoteVisualTarget : getStreamPostNode(ctx, settledTargetPostNumber); } } if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) { throw new Error(`目标楼层 #${settledTargetPostNumber} 布局稳定校验失败,请重试`); } } setReaderOpenPhase('activate-reader'); readerSession.state = 'active'; readerRefreshBtn.disabled = false; if (initialQuoteSourceTarget) await returnToReaderQuoteSource(initialQuoteSourceTarget, ctx); ctx.timeline.update(); removeOwnedNode(maskEl); if (unavailableTargetPostNumber) { showSelectionToast(`楼层 #${unavailableTargetPostNumber} 已不可用,已跳到相邻楼层 #${targetPostNumber}`); } if (sessionAcceptsWork()) rememberTopic(topic, targetPostNumber || 1); } catch (err) { if (disposed || !readerSession.isCurrent() || !overlay.isConnected) return; const failedPhase = readerOpenPhase; console.error('[LDP] reader open failed', { phase: failedPhase, topicId: String(topicId || ''), targetPostNumber, sessionId: readerSessionId, }, err); readerSession.state = 'failed'; overlay.classList.remove('ldp-initial-target-layout'); ctx.initialTargetPositionPending = false; const endpointRateLimitedFailure = Number(err && err.status || 0) === 429 && err && err.rateLimitScope === 'endpoint'; const rateLimitedFailure = Number(err && err.status || 0) === 429 && !endpointRateLimitedFailure; stopReaderRuntime({ preserveShell: readerWorkspace.isEmbedded() }); removeOwnedNode(maskEl); const errorEl = document.createElement('div'); errorEl.className = 'ldp-error'; errorEl.setAttribute('role', 'alert'); errorEl.innerHTML = ` <div class="ldp-error-message"></div> <div class="ldp-error-actions"> <button class="ldp-error-retry" type="button">重新加载</button> ${rateLimitedFailure ? `<a class="ldp-error-challenge" href="${BASE}/" target="_blank" rel="noopener" aria-label="在独立浮窗打开 LINUX DO 官网完成 Cloudflare 验证">打开 Cloudflare 验证</a>` : ''} <button class="ldp-error-close" type="button">关闭阅读器</button> </div>`; errorEl.querySelector('.ldp-error-message').textContent = rateLimitedFailure ? '请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。' : endpointRateLimitedFailure ? '当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。' : `加载失败(${failedPhase}):${err && err.message ? err.message : '未知错误'}`; body.replaceChildren(errorEl); const retryButton = errorEl.querySelector('.ldp-error-retry'); const errorChallenge = errorEl.querySelector('.ldp-error-challenge'); const errorCloseButton = errorEl.querySelector('.ldp-error-close'); let retryOpening = false; let challengeAutoOpened = false; const syncRetryState = () => { clearFailureRetryTimer(); if (!overlay.isConnected || readerSession.state !== 'failed') return; const remaining = rateLimitCooldownRemaining(); retryButton.disabled = retryOpening || remaining > 0; retryButton.textContent = retryOpening ? '正在重新加载…' : remaining > 0 ? `限流冷却中(${Math.ceil(remaining / 1000)} 秒)` : '重新加载'; if (remaining > 0) { failureRetryTimer = setTimeout(syncRetryState, Math.min(1000, remaining + 20)); } else if (rateLimitedFailure && !challengeAutoOpened && errorChallenge) { challengeAutoOpened = true; openCloudflareChallengePopup(errorChallenge.href); } }; retryButton.addEventListener('click', () => { if (retryButton.disabled || retryOpening) return; retryOpening = true; syncRetryState(); Promise.resolve(openModal(topicId, { targetPostNumber, topicNavMetadata: sourceTopicNavMetadata, restoreViewport, historyNavigation, settleTargetJump, })).catch((retryError) => { console.error('[LDP] reader retry failed', retryError); retryOpening = false; syncRetryState(); }); }); errorCloseButton.addEventListener('click', onReaderCloseClick); syncRetryState(); } } /* ============ 14. 接管帖子路由 ============ */ function shouldBypassReaderUrl(url) { let u; try { u = new URL(url, location.href); } catch (e) { return false; } return u.searchParams.has(NATIVE_BYPASS_PARAM); } function consumeNativeBypassTabSignal() { try { const bypass = sessionStorage.getItem(NATIVE_BYPASS_TAB_KEY) === '1'; if (bypass) sessionStorage.removeItem(NATIVE_BYPASS_TAB_KEY); return bypass; } catch (e) { return false; } } function openNativeTopicTab(url) { let tab = null; try { tab = window.open('about:blank', '_blank'); } catch (e) {} if (!tab) return false; try { tab.sessionStorage.setItem(NATIVE_BYPASS_TAB_KEY, '1'); } catch (e) {} try { tab.opener = null; } catch (e) {} try { tab.location.replace(url); } catch (e) { tab.location.href = url; } return true; } let cloudflareChallengePopup = null; let cloudflareChallengePopupTimer = 0; let cloudflareChallengePopupState = null; let cloudflareChallengeLeaseHeartbeatAt = 0; const cloudflareChallengeInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; function readCloudflareChallengeLease() { try { const lease = JSON.parse(localStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY) || 'null'); return lease && typeof lease === 'object' ? lease : null; } catch (e) { return null; } } function writeCloudflareChallengeLease(state, ttl = LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL) { const at = Date.now(); if (state === 'active') cloudflareChallengeLeaseHeartbeatAt = at; const lease = { owner: cloudflareChallengeInstanceId, state, updatedAt: at, expiresAt: at + ttl, }; try { localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY, JSON.stringify(lease)); } catch (e) {} return lease; } function releaseCloudflareChallengeLease() { const lease = readCloudflareChallengeLease(); if (!lease || lease.owner !== cloudflareChallengeInstanceId) return; try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY); } catch (e) {} } function onCloudflareChallengeLeaseChange(event) { if (event.key === LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY && event.newValue) { let result = null; try { result = JSON.parse(event.newValue); } catch (e) {} if (!result || result.owner !== cloudflareChallengeInstanceId) return; try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY); } catch (e) {} const state = cloudflareChallengePopupState; if (state && state.owner === result.owner) verifyCloudflareChallengePopupState(state, result); return; } if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return; let lease = null; try { lease = JSON.parse(event.newValue); } catch (e) {} if (!lease || lease.owner === cloudflareChallengeInstanceId || lease.state !== 'passed') return; const resetRate = resetReaderRateAfterChallenge(); retryPendingReaderReadTracking(); if (resetRate) showSelectionToast('验证已在另一窗口通过,已重置请求流限速'); } window.addEventListener('storage', onCloudflareChallengeLeaseChange); function stopCloudflareChallengePopupMonitor() { if (cloudflareChallengePopupTimer) clearInterval(cloudflareChallengePopupTimer); cloudflareChallengePopupTimer = 0; } function cloudflareChallengePageInfo(popup) { try { const popupDocument = popup && popup.document; if (!popupDocument) return { accessible: false, challenge: false, ready: false, status: 0 }; const href = String(popup.location && popup.location.href || ''); const title = String(popupDocument.title || ''); const challenge = !!( popup._cf_chl_opt || popupDocument.querySelector( 'iframe[src*="challenges.cloudflare.com"],script[src*="/cdn-cgi/challenge-platform/"],#challenge-running' ) || /^(?:Just a moment|请稍候)/i.test(title) ); const navigation = popup.performance && popup.performance.getEntriesByType ? popup.performance.getEntriesByType('navigation')[0] : null; return { accessible: true, challenge, ready: popupDocument.readyState === 'complete' && href !== 'about:blank', status: Math.max(0, Number(navigation && navigation.responseStatus) || 0), }; } catch (e) { return { accessible: false, challenge: false, ready: false, status: 0 }; } } function cloudflareChallengePassed(popup) { try { const popupDocument = popup && popup.document; if (!popupDocument) return false; return !!popupDocument.querySelector('meta[name="discourse-base-uri"],#main-outlet'); } catch (e) { return false; } } function retryPendingReaderReadTracking() { const tracker = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.activeContext?.tracker; if (tracker && typeof tracker.retryPending === 'function') tracker.retryPending(); } function completeCloudflareChallengePopup(message) { const resetRate = resetReaderRateAfterChallenge(); retryPendingReaderReadTracking(); writeCloudflareChallengeLease('passed', 10 * 1000); try { cloudflareChallengePopup.close(); } catch (e) {} cloudflareChallengePopup = null; cloudflareChallengePopupState = null; stopCloudflareChallengePopupMonitor(); try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY); } catch (e) {} if (message) showSelectionToast(message); else if (resetRate) showSelectionToast('验证已通过,已重置请求流限速'); } function verifyCloudflareChallengePopupState(state, result = {}) { if (!state || state.verificationStarted || state.verificationFailed) return; state.verificationStarted = true; const status = Math.max(0, Number(result.status) || 0); if (result.challenged !== true && status >= 200 && status < 300) { if (state.onFormSuccess) state.onFormSuccess(); completeCloudflareChallengePopup('已收到后端 2xx,楼层已标记为已读'); return; } if (result.challenged !== true || !state.verify) { state.verificationFailed = true; releaseCloudflareChallengeLease(); showSelectionToast('验证请求未完成,浮窗已保留;楼层仍保持未读'); return; } Promise.resolve(state.verify()).then((verified) => { if (cloudflareChallengePopupState !== state) return; if (verified === true) { completeCloudflareChallengePopup('验证通过且后端已返回 2xx,楼层已标记为已读'); return; } state.verificationFailed = true; releaseCloudflareChallengeLease(); showSelectionToast('验证后接口仍未返回 2xx;浮窗已保留,本次不会重复触发'); }).catch((error) => { console.warn('[LDP] verify read tracking challenge failed', error); if (cloudflareChallengePopupState !== state) return; state.verificationFailed = true; releaseCloudflareChallengeLease(); showSelectionToast('验证后接口仍未返回 2xx;浮窗已保留,本次不会重复触发'); }); } async function submitCloudflareChallengePost(popup, url, params, state) { const token = await csrfToken(); if (!popup || popup.closed || cloudflareChallengePopup !== popup || cloudflareChallengePopupState !== state) { return false; } const target = 'ldp-cloudflare-challenge'; try { popup.name = target; } catch (e) {} const form = document.createElement('form'); form.method = 'POST'; const challengeUrl = new URL(url, location.href); challengeUrl.searchParams.set(LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM, state.owner); form.action = challengeUrl.href; form.target = target; form.hidden = true; const appendField = (name, value) => { const input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = String(value); form.appendChild(input); }; appendField('authenticity_token', token); Object.entries(params || {}).forEach(([name, value]) => { if (Array.isArray(value)) value.forEach((item) => appendField(`${name}[]`, item)); else if (value != null) appendField(name, value); }); document.body.appendChild(form); state.formSubmitted = true; state.submittedAt = Date.now(); try { form.submit(); } finally { form.remove(); } return true; } function openCloudflareChallengePopup(url, options = {}) { if (cloudflareChallengePopupState?.formParams && !cloudflareChallengePopupState.verificationFailed) { try { cloudflareChallengePopup.focus(); } catch (e) {} return true; } if (cloudflareChallengePopup && !cloudflareChallengePopup.closed) { try { cloudflareChallengePopup.focus(); } catch (e) {} return true; } const existingLease = readCloudflareChallengeLease(); if (existingLease && existingLease.expiresAt > Date.now() && existingLease.owner !== cloudflareChallengeInstanceId) { if (existingLease.state === 'passed') { const resetRate = resetReaderRateAfterChallenge(); if (resetRate) showSelectionToast('验证已通过,已重置请求流限速'); } else { showSelectionToast('已有一个过盾窗口正在处理,请在该窗口完成验证'); } return true; } writeCloudflareChallengeLease('active'); const availableScreen = window.screen; const width = Math.min(760, Math.max(420, availableScreen.availWidth || 760)); const height = Math.min(720, Math.max(520, availableScreen.availHeight || 720)); const left = Math.max(0, Math.round((availableScreen.availLeft || 0) + ((availableScreen.availWidth || width) - width) / 2)); const top = Math.max(0, Math.round((availableScreen.availTop || 0) + ((availableScreen.availHeight || height) - height) / 2)); const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`; let popup = null; try { popup = window.open(options.formParams ? 'about:blank' : url, 'ldp-cloudflare-challenge', features); } catch (e) {} if (!popup) { releaseCloudflareChallengeLease(); showSelectionToast('验证浮窗被浏览器拦截,请允许本站弹出式窗口'); return false; } cloudflareChallengePopup = popup; const popupState = { owner: cloudflareChallengeInstanceId, formParams: options.formParams || null, formSubmitted: false, submittedAt: 0, detachedAt: 0, sawChallenge: false, verificationStarted: false, verificationFailed: false, verify: typeof options.verify === 'function' ? options.verify : null, onFormSuccess: typeof options.onFormSuccess === 'function' ? options.onFormSuccess : null, }; cloudflareChallengePopupState = popupState; try { popup.focus(); } catch (e) {} if (popupState.formParams) { showSelectionToast('请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读'); void submitCloudflareChallengePost(popup, url, popupState.formParams, popupState).catch((error) => { console.warn('[LDP] open read tracking challenge failed', error); popupState.verificationFailed = true; releaseCloudflareChallengeLease(); showSelectionToast('无法提交验证请求,楼层仍保持未读'); }); } stopCloudflareChallengePopupMonitor(); cloudflareChallengePopupTimer = setInterval(() => { if (!cloudflareChallengePopup || cloudflareChallengePopup.closed) { const state = cloudflareChallengePopupState; if (cloudflareChallengePopup && state && state.formParams && state.formSubmitted && !state.verificationStarted && !state.verificationFailed) { if (!state.detachedAt) state.detachedAt = Date.now(); if (Date.now() - state.detachedAt < 2 * 60 * 1000) { if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000) { writeCloudflareChallengeLease('active', 2 * 60 * 1000); } return; } } cloudflareChallengePopup = null; cloudflareChallengePopupState = null; stopCloudflareChallengePopupMonitor(); releaseCloudflareChallengeLease(); return; } if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000) { writeCloudflareChallengeLease('active'); } const state = cloudflareChallengePopupState; const pageInfo = cloudflareChallengePageInfo(cloudflareChallengePopup); if (pageInfo.challenge) { if (state) state.sawChallenge = true; return; } if (!state || state.verificationFailed || !pageInfo.ready) return; if (!state.formParams) { if (!state.sawChallenge || !cloudflareChallengePassed(cloudflareChallengePopup)) return; completeCloudflareChallengePopup(); return; } if (!state.formSubmitted || Date.now() - state.submittedAt < 500 || state.verificationStarted) return; if (!state.sawChallenge && !(pageInfo.status >= 200 && pageInfo.status < 300)) return; verifyCloudflareChallengePopupState(state, { status: pageInfo.status, challenged: state.sawChallenge, }); }, 250); return true; } function stripBypassParamFromUrl() { try { const u = new URL(location.href); if (!u.searchParams.has(NATIVE_BYPASS_PARAM)) return; u.searchParams.delete(NATIVE_BYPASS_PARAM); history.replaceState(history.state, '', u.pathname + u.search + u.hash); } catch (e) {} } function extractTopicRouteFromUrl(url) { let u; try { u = new URL(url, location.href); } catch (e) { return null; } if (/^https?:$/i.test(u.protocol) && u.origin !== BASE) return null; const parts = u.pathname.split('/').filter(Boolean); if (parts[0] !== 't') return null; const segments = parts.slice(1); if (/^\d+$/.test(segments[0] || '')) { const topicId = segments[0]; const postNumber = /^\d+$/.test(segments[1] || '') ? segments[1] : ''; return { topicId, postNumber: postNumber ? +postNumber : 0 }; } const topicId = segments[1]; const postNumber = /^\d+$/.test(segments[2] || '') ? segments[2] : ''; return /^\d+$/.test(topicId || '') ? { topicId, postNumber: postNumber ? +postNumber : 0 } : null; } function topicUrlReaderStartPostNumber(route) { if (!route) return 0; return PREFS.openTopicsAtFirstPost ? 1 : (+route.postNumber || 0); } function topicLinkReaderStartPostNumber(route, link) { if (link && link.dataset.ldpPreserveTargetPost === '1') return +route.postNumber || 0; return topicUrlReaderStartPostNumber(route); } function runWhenBodyReady(fn) { if (document.body) fn(); else document.addEventListener('DOMContentLoaded', fn, { once: true }); } function hideNativeReaderTrigger() { document.documentElement.classList.remove('ldp-native-reader-trigger-visible'); readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((item) => { item.hidden = true; }); const button = readerPortalQuery('.ldp-native-reader-trigger'); if (button && !button.closest('.ldp-native-reader-trigger-item')) button.hidden = true; } function nativeReaderTriggerAvatarHost() { return document.querySelector('.d-header-icons .current-user:has(img.avatar)'); } function currentNativeReaderTriggerTarget() { const route = extractTopicRouteFromUrl(location.href); if (route) return { ...route, source: 'route', title: '' }; const firstHistoryTopic = orderedTopicHistory()[0]; const topicId = Number(firstHistoryTopic && firstHistoryTopic.topicId); if (!topicId) return null; return { topicId, postNumber: Math.max(1, Number(firstHistoryTopic.postNumber) || 1), source: 'history', title: String(firstHistoryTopic.title || ''), }; } function syncNativeReaderTrigger() { if (!document.body) return; const target = currentNativeReaderTriggerTarget(); const topicId = target && String(target.topicId); if (CURRENT_OVERLAY) { hideNativeReaderTrigger(); return; } const avatarHost = nativeReaderTriggerAvatarHost(); if (!avatarHost) { hideNativeReaderTrigger(); return; } document.documentElement.classList.add('ldp-native-reader-trigger-visible'); let item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]'); if (!item) { item = document.createElement('div'); item.className = 'ldp-native-reader-trigger-item'; item.dataset.ldpPortal = '1'; } if (item.parentElement !== avatarHost) avatarHost.appendChild(item); readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((candidate) => { if (candidate !== item) candidate.hidden = true; }); let button = item.querySelector('.ldp-native-reader-trigger'); if (!button) { button = document.createElement('button'); button.className = 'ldp-native-reader-trigger'; button.type = 'button'; button.innerHTML = icon('maximize2'); button.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); const initialTopicId = Number(button.dataset.topicId); const initialPostNumber = Number(button.dataset.postNumber) || 0; const initialSource = button.dataset.triggerSource; if (!initialTopicId) return; AUTO_OPEN_SUPPRESSED_TOPIC_ID = ''; hideNativeReaderTrigger(); if (document.documentElement.classList.contains('ldp-reader-sidepanel-active') && !await closeChromeSidePanelForReader()) { return stopDirectTopicTakeover(syncNativeReaderTrigger, String(initialTopicId)); } const currentRoute = extractTopicRouteFromUrl(location.href); if (initialSource === 'route') { if (!currentRoute || Number(currentRoute.topicId) !== initialTopicId) { syncNativeReaderTrigger(); return; } setPref('topicReaderMode', 'floating'); document.documentElement.classList.add('ldp-route-takeover'); } else if (currentRoute) { syncNativeReaderTrigger(); return; } openModal(initialTopicId, { targetPostNumber: initialSource === 'route' ? topicUrlReaderStartPostNumber(currentRoute) : initialPostNumber, topicNavMetadata: readTopicNavMetadata(null, initialTopicId), }); }); item.appendChild(button); } item.style.removeProperty('left'); item.style.removeProperty('top'); delete item.dataset.ldpPlacement; item.hidden = false; button.hidden = false; button.dataset.topicId = topicId || ''; button.dataset.triggerSource = target ? target.source : 'empty-history'; const startPostNumber = target ? target.source === 'route' ? topicUrlReaderStartPostNumber(target) : target.postNumber : 0; button.dataset.postNumber = String(startPostNumber || ''); const sidePanelActive = document.documentElement.classList.contains('ldp-reader-sidepanel-active'); const label = !target ? '暂无浏览历史' : target.source === 'history' ? target.title ? `打开历史首项:${target.title}` : '打开历史首项' : sidePanelActive ? '切换到浮窗阅读器' : PREFS.openTopicsAtFirstPost ? '从 #1 打开浮窗阅读器' : target.postNumber ? `从 #${target.postNumber} 打开浮窗阅读器` : '打开浮窗阅读器'; button.setAttribute('aria-label', label); } function installNativeReaderTriggerObserver() { if (!document.body || document.body._ldpNativeReaderTriggerObserver) return; const relevantSelector = '.d-header,.d-header-wrap,.d-header-icons,.current-user,' + '.ldp-native-reader-trigger-item[data-ldp-portal="1"]'; const relevantContainerSelector = '.discourse-root,.d-header-wrap,header'; const mutationNodeIsRelevant = (node) => { if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; if (node.matches(relevantSelector)) return true; return node.matches(relevantContainerSelector) && !!node.querySelector(relevantSelector); }; const mutationIsRelevant = (mutation) => { const target = mutation.target; if (target && target.nodeType === Node.ELEMENT_NODE && target.closest(relevantSelector)) return true; return [...mutation.addedNodes, ...mutation.removedNodes].some(mutationNodeIsRelevant); }; let syncFrame = 0; const observer = new MutationObserver((mutations) => { if (!mutations.some(mutationIsRelevant) || syncFrame) return; syncFrame = requestAnimationFrame(() => { syncFrame = 0; const avatarHost = nativeReaderTriggerAvatarHost(); const item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]'); if (avatarHost && (!item || item.parentElement !== avatarHost)) syncNativeReaderTrigger(); }); }); observer.observe(document.body, { childList: true, subtree: true }); document.body._ldpNativeReaderTriggerObserver = observer; } function stopDirectTopicTakeover(triggerAction, suppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID) { document.documentElement.classList.remove('ldp-route-takeover'); AUTO_OPEN_SUPPRESSED_TOPIC_ID = suppressedTopicId; triggerAction(); return false; } async function takeoverDirectTopicRoute() { if (shouldBypassReaderUrl(location.href)) { return stopDirectTopicTakeover(hideNativeReaderTrigger); } const route = extractTopicRouteFromUrl(location.href); if (!route) { return stopDirectTopicTakeover(syncNativeReaderTrigger, ''); } const topicId = String(route.topicId); const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace; if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId && activeReaderWorkspace?.isEmbedded()) { return stopDirectTopicTakeover(syncNativeReaderTrigger); } if (document.documentElement.classList.contains('ldp-reader-sidepanel-active') && !await closeChromeSidePanelForReader()) { return stopDirectTopicTakeover(syncNativeReaderTrigger, topicId); } const currentRoute = extractTopicRouteFromUrl(location.href); if (!currentRoute || String(currentRoute.topicId) !== topicId) { return stopDirectTopicTakeover(hideNativeReaderTrigger); } if (AUTO_OPEN_SUPPRESSED_TOPIC_ID && AUTO_OPEN_SUPPRESSED_TOPIC_ID !== topicId) { AUTO_OPEN_SUPPRESSED_TOPIC_ID = ''; } if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId) { return stopDirectTopicTakeover(syncNativeReaderTrigger); } hideNativeReaderTrigger(); if (CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId === topicId) return true; document.documentElement.classList.add('ldp-route-takeover'); openModal(currentRoute.topicId, { targetPostNumber: topicUrlReaderStartPostNumber(currentRoute), topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId), }); return true; } function installRouteTakeoverHooks() { ['pushState', 'replaceState'].forEach((method) => { const nativeMethod = history[method]; history[method] = function (...args) { const ret = nativeMethod.apply(this, args); setTimeout(takeoverDirectTopicRoute, 0); return ret; }; }); window.addEventListener('popstate', () => setTimeout(takeoverDirectTopicRoute, 0)); } function captureHostTopicPointerAnchor(event, topicId) { if (event.type !== 'click' || event.detail <= 0) return null; const card = eventPathClosest(event, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item'); if (!card || card.closest('.ldp-overlay')) return null; const rect = card.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; return { card, topicId: String(topicId || ''), clientY: event.clientY, offsetY: Math.max(0, Math.min(rect.height, event.clientY - rect.top)), }; } function restoreHostTopicPointerAnchor(anchor) { if (!anchor) return false; let card = anchor.card && anchor.card.isConnected ? anchor.card : null; if (!card && anchor.topicId) { const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item'; card = [...document.querySelectorAll(cardSelector)].find((candidate) => { if (candidate.closest('.ldp-overlay')) return false; const directId = candidate.dataset.topicId || candidate.getAttribute('data-topic-id'); const link = candidate.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]'); return String(directId || extractTopicRouteFromUrl(link && link.href)?.topicId || '') === anchor.topicId; }) || null; } if (!card) return false; document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((candidate) => { if (candidate !== card) candidate.removeAttribute('data-ldp-reader-active-topic'); }); card.dataset.ldpReaderActiveTopic = 'true'; const rect = card.getBoundingClientRect(); const delta = rect.top + Number(anchor.offsetY || 0) - Number(anchor.clientY || 0); if (Math.abs(delta) >= .5) window.scrollBy({ top: delta, behavior: 'auto' }); return true; } function handleTopicLinkTrigger(e) { if (e.type === 'click' && e.button && e.button !== 0) return false; let a = eventPathClosest(e, 'a[href]'); if (!a && document.documentElement.classList.contains('ldp-reader-workspace')) { const card = eventPathClosest(e, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item'); const interactive = eventPathClosest(e, 'button,input,select,textarea,[role="button"],[contenteditable="true"]'); if (card && !card.closest('.ldp-overlay') && !interactive) { a = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'); } } if (!a) return false; if (a.classList.contains('ldp-rate-limit-challenge') || a.classList.contains('ldp-error-challenge')) { e.preventDefault(); e.stopPropagation(); openCloudflareChallengePopup(a.href); return true; } const notificationId = markClickedNotificationRead(a.closest('.ldp-notification-item')); if (notificationId > 0) { void apiSend(`${BASE}/notifications/mark-read`, 'PUT', { id: notificationId }, null, { surviveReaderSwitch: true, }).catch((error) => { console.warn('[LDP] mark notification read failed', error); }); } const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace; if (activeReaderWorkspace?.isEmbedded() && a.getRootNode() === READER_PORTAL_ROOT && !a.classList.contains('ldp-history-link') && !a.classList.contains('ldp-rate-limit-challenge') && !a.classList.contains('ldp-error-challenge') && openEmbeddedReaderLinkInHost(e, a, activeReaderWorkspace)) return true; if (a.classList.contains('ldp-open')) return false; if (shouldBypassReaderUrl(a.href)) return false; const route = extractTopicRouteFromUrl(a.getAttribute('href') || a.href); if (!route) return false; const hostTopicPointerAnchor = captureHostTopicPointerAnchor(e, route.topicId); if (hostTopicPointerAnchor && typeof a.blur === 'function') a.blur(); e.preventDefault(); e.stopPropagation(); AUTO_OPEN_SUPPRESSED_TOPIC_ID = ''; hideNativeReaderTrigger(); openModal(route.topicId, { targetPostNumber: topicLinkReaderStartPostNumber(route, a), topicNavMetadata: readTopicNavMetadata(a), hostTopicPointerAnchor, }); return true; } function handleEmbeddedHostRefreshKeyDown(event) { if (event.key !== 'F5') return false; const readerShell = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell; const readerWorkspace = readerShell && readerShell.readerWorkspace; if (!readerWorkspace || !readerWorkspace.isEmbedded()) return false; event.preventDefault(); event.stopImmediatePropagation(); if (!refreshDiscourseHostRoute()) { showSelectionToast('LINUX DO 宿主刷新暂不可用,请稍后重试'); } return true; } document.addEventListener('keydown', handleEmbeddedHostRefreshKeyDown, true); document.addEventListener('click', handleTopicLinkTrigger, true); installRouteTakeoverHooks(); runWhenBodyReady(async () => { ensureReaderStyleMounted(); installNativeReaderTriggerObserver(); let directTopicTakenOver = false; try { directTopicTakenOver = await takeoverDirectTopicRoute(); } catch (error) { console.warn('[LDP] direct topic takeover failed', error); } if (directTopicTakenOver) return; try { await restoreEmbeddedReaderAfterReload(); } catch (error) { console.warn('[LDP] embedded reader reload restore failed', error); } syncNativeReaderTrigger(); }); })();