LinuxDO快速预览工具:悬浮入口、抽屉模式,支持话题摘要和详情页预览。
// ==UserScript==
// @name LD Peek
// @namespace https://linux.do/
// @version 0.4.74
// @description LinuxDO快速预览工具:悬浮入口、抽屉模式,支持话题摘要和详情页预览。
// @author kaibush
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgcng9IjI4IiBmaWxsPSIjMWYyOTM3Ii8+PHBhdGggZD0iTTI0IDc4YzEyLTIwIDI4LTMwIDQ4LTMwczM2IDEwIDQ4IDMwYy0xMiAyMC0yOCAzMC00OCAzMFMzNiA5OCAyNCA3OFoiIGZpbGw9IiM1NmI4YWEiLz48Y2lyY2xlIGN4PSI3MiIgY3k9Ijc4IiByPSIxNiIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjcyIiBjeT0iNzgiIHI9IjgiIGZpbGw9IiMxZjI5MzciLz48cGF0aCBkPSJNMjQgMjhoMTR2NDZoMjZ2MTJIMjRWMjhabTUwIDBoMThjMjUgMCAzOSAxMSAzOSAyOXMtMTQgMjktMzkgMjlINzRWMjhabTE0IDEydjM0aDVjMTUgMCAyMy02IDIzLTE3cy04LTE3LTIzLTE3aC01WiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==
// @match https://linux.do/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_notification
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
// 部分浏览器里 Tampermonkey 仍可能把脚本注入同源 iframe。
// 抽屉详情模式本身会创建 iframe,所以除了 @noframes 还保留运行时防护。
try {
if (window.self !== window.top) return;
} catch (_) {
return;
}
const APP_NAME = 'LD Peek';
const LOG_PREFIX = `[${APP_NAME}]`;
const NAME = 'ldpeek';
// 存储 key 保留旧前缀,避免已安装用户丢失设置和阅读记忆。
const PREF_KEY = 'linuxdo_eye_preview_settings';
const LEGACY_PREF_KEY = 'linuxdo_eye_preview_mode';
const MEMORY_KEY = 'linuxdo_eye_preview_seen_topics';
const FAVORITES_KEY = 'linuxdo_eye_preview_favorites';
const READ_LATER_KEY = 'linuxdo_eye_preview_read_later_queue';
const PAGE_NAV_KEY = 'linuxdo_eye_preview_page_nav';
const DRAWER_STATE_KEY = 'linuxdo_eye_preview_drawer_state';
const SITE_CATEGORIES_KEY = 'linuxdo_eye_preview_site_categories';
const MAX_MEMORY = 500;
const MAX_FAVORITES = 100;
const MAX_READ_LATER = 100;
const MAX_TOPIC_CACHE = 10;
const MAX_RECENT_VIEW = 20;
const MAX_PAGE_NAV = 20;
const MAX_FRAME_HISTORY = 50;
const FLOATING_BUTTON_SIZE = 40;
const FLOATING_LONG_PRESS = 340;
const FLOATING_PIE_RADIUS = 100;
const FLOATING_EDGE_DISTANCE = 12;
const FLOATING_EDGE_PEEK = 15;
const BADGE_REFRESH_DELAY = 500;
// iframe 主动加载后,Discourse 可能延迟规范化 URL;这段时间只替换当前历史项。
const FRAME_HISTORY_REPLACE_WINDOW = 1200;
const FRAME_USER_NAV_WINDOW = 8000;
const DRAWER_TRACK_VIEW_DELAY_MIN = 2000;
const DRAWER_TRACK_VIEW_DELAY_MAX = 3500;
const AUTO_SCROLL_SPEED_LEVELS = Object.freeze({
fast: {
label: '稍快',
help: '适合快速浏览长文内容',
min: 54,
max: 86,
initial: 64,
speedChangeMin: 1200,
speedChangeMax: 3600,
pauseChance: 0.1
},
normal: {
label: '正常',
help: '默认阅读速度',
min: 32,
max: 54,
initial: 42,
speedChangeMin: 1400,
speedChangeMax: 4400,
pauseChance: 0.12
},
slow: {
label: '慢速',
help: '适合需要细读的内容',
min: 16,
max: 32,
initial: 24,
speedChangeMin: 1800,
speedChangeMax: 5600,
pauseChance: 0.14
}
});
const AUTO_SCROLL_DEFAULT_SPEED_LEVEL = 'normal';
const AUTO_SCROLL_DEFAULT_SPEED = AUTO_SCROLL_SPEED_LEVELS[AUTO_SCROLL_DEFAULT_SPEED_LEVEL].initial;
const AUTO_SCROLL_PAUSE_MIN = 500;
const AUTO_SCROLL_PAUSE_MAX = 3000;
const PREVIEW_PREFETCH_DELAY = 180;
const PREVIEW_PREFETCH_TTL = 60 * 1000;
const SITE_CATEGORIES_TTL = 30 * 24 * 60 * 60 * 1000;
const FRAME_LOAD_WAIT = 12;
const READ_LATER_KEEP_HINT = '打开不会自动移出;请点 × 手动移除,避免误删阅读列表。';
const SUPPORT_LINKS = Object.freeze([
{
id: 'ldc-1',
title: '❤️ 精神股东',
amount: '1 LDC',
desc: '1 LDC 买不了吃亏,但能让作者知道有人在乎这个项目',
href: 'https://credit.linux.do/paying/online?token=7e7717e183e46ef4a927271660c4059f0d0b8c9d5dd3e2fee1e2944b92633a42'
},
{
id: 'ldc-5',
title: '⭐ 金牌股东',
amount: '5 LDC',
desc: '进入作者“优先响应名单”,你的 Issue 和 PR 会被高亮处理',
href: 'https://credit.linux.do/paying/online?token=241147af3294a981944aea5a7c6d6c6868fdabed3122c3dc6c40c35f90f13eb4'
}
]);
const DEFAULT_PREFS = Object.freeze({
mode: 'summary',
threadSource: 'nested',
width: 760,
height: 100,
showReadBadges: true,
showEffectiveBadges: true,
showCategoryColors: true,
forceCreatedOrder: true,
clickTopicToDrawer: false,
trackDrawerViews: false,
trackDrawerViewsLive: true,
trackDrawerViewsTopicLinks: true,
trackDrawerViewsReadLater: true,
trackDrawerViewsRecent: true,
trackDrawerViewsResume: true,
trackDrawerViewsFavorites: false,
autoScrollSpeed: AUTO_SCROLL_DEFAULT_SPEED_LEVEL,
keywordBlockList: '',
keywordHighlightList: '',
liveEnabled: false,
livePauseHidden: true,
liveCreatedOrder: true,
liveCategoryFilter: '',
prefLeft: null,
prefTop: null,
prefEdgeState: null,
memoryLimit: MAX_MEMORY,
favoriteLimit: MAX_FAVORITES,
recentLimit: MAX_RECENT_VIEW,
pageNavLimit: MAX_PAGE_NAV,
topicCacheLimit: MAX_TOPIC_CACHE,
frameHistoryLimit: MAX_FRAME_HISTORY,
previewPrefetchDelay: PREVIEW_PREFETCH_DELAY,
previewCacheTtl: PREVIEW_PREFETCH_TTL / 1000,
badgeRefreshDelay: BADGE_REFRESH_DELAY,
frameLoadWait: FRAME_LOAD_WAIT,
livePollMinutes: 10,
liveMaxTopics: 50
});
const MODES = Object.freeze({
summary: { label: '预览', help: '读取楼主正文' },
thread: { label: '详情', help: '打开详情页面' }
});
const THREAD_SOURCES = Object.freeze({
nested: { label: '楼层视图', help: '使用 /n/topic/{id},适合抽屉内阅读' },
original: { label: '原帖页面', help: '使用标题上的原始链接,和直接打开话题一致' }
});
const TRACK_DRAWER_VIEW_PREFS = Object.freeze([
'trackDrawerViewsLive',
'trackDrawerViewsTopicLinks',
'trackDrawerViewsReadLater',
'trackDrawerViewsRecent',
'trackDrawerViewsResume',
'trackDrawerViewsFavorites'
]);
const DRAWER_WIDTH_PRESETS = Object.freeze([
{ id: 'compact', label: '紧凑', width: 400, help: '适合快速查看' },
{ id: 'standard', label: '标准', width: 760, help: '默认阅读宽度' },
{ id: 'wide', label: '宽屏', width: 960, help: '更宽的正文区域' },
{ id: 'immersive', label: '沉浸', width: 1280, help: '最大预览宽度' }
]);
const TUNING_FIELDS = Object.freeze({
memoryLimit: { label: '已读保存', help: '本地最多保存多少个已读话题', min: 20, max: 500, step: 10, unit: '条' },
favoriteLimit: { label: '收藏保存', help: '本地最多保存多少个收藏话题', min: 20, max: 300, step: 10, unit: '条' },
recentLimit: { label: '最近显示', help: '最近查看列表展示数量', min: 5, max: 50, step: 5, unit: '条' },
pageNavLimit: { label: '导航标签', help: '导航栏最多保留多少个页面', min: 5, max: 30, step: 1, unit: '页' },
topicCacheLimit: { label: '预览缓存', help: '话题 JSON 缓存数量', min: 3, max: 30, step: 1, unit: '个' },
frameHistoryLimit: { label: '详情历史', help: 'iframe 前进后退历史数量', min: 10, max: 100, step: 5, unit: '条' },
previewPrefetchDelay: { label: '预加载等待', help: '悬浮图标后延迟多久预取', min: 0, max: 1000, step: 20, unit: 'ms' },
previewCacheTtl: { label: '缓存有效期', help: '重复悬浮预取的间隔', min: 10, max: 600, step: 10, unit: '秒' },
badgeRefreshDelay: { label: '标记刷新', help: '已读和话题生效标记刷新等待', min: 100, max: 3000, step: 100, unit: 'ms' },
frameLoadWait: { label: '详情等待', help: 'iframe 加载多久后提示重试', min: 5, max: 60, step: 1, unit: '秒' },
livePollMinutes: { label: '实时轮询', help: '开启后多久检查一次最新帖子', min: 3, max: 60, step: 1, unit: '分钟' },
liveMaxTopics: { label: '实时显示', help: '实时新帖窗口最多显示多少条', min: 5, max: 150, step: 5, unit: '条' }
});
const TOPIC_ANCHOR_QUERY = [
'a.raw-link[href]',
'a.title[href]',
'a.search-link[href]',
'a[href^="/t/"]',
'a[href*="linux.do/t/"]'
].join(',');
const BADGE_ANCHOR_QUERY = 'a.title.raw-link.raw-topic-link[data-topic-id][href]';
const LAST_VIEWED_ANCHOR_QUERY = `${BADGE_ANCHOR_QUERY}, a.search-link[href]`;
// 集中管理偏好设置:抽屉内设置、悬浮设置和油猴菜单都写入这里。
const Prefs = {
value: { ...DEFAULT_PREFS },
load() {
let stored = null;
try {
stored = typeof GM_getValue === 'function'
? GM_getValue(PREF_KEY, null)
: window.localStorage.getItem(PREF_KEY);
} catch (_) {
stored = null;
}
if (!stored) {
try {
stored = typeof GM_getValue === 'function'
? GM_getValue(LEGACY_PREF_KEY, null)
: window.localStorage.getItem(LEGACY_PREF_KEY);
} catch (_) {
stored = null;
}
}
this.value = this.normalize(stored);
return this.value;
},
save(next) {
this.value = this.normalize({ ...this.value, ...next });
try {
const payload = JSON.stringify(this.value);
if (typeof GM_setValue === 'function') {
GM_setValue(PREF_KEY, payload);
} else {
window.localStorage.setItem(PREF_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 设置保存失败`, error);
}
return this.value;
},
toggle() {
return this.save({ mode: this.value.mode === 'summary' ? 'thread' : 'summary' });
},
normalize(raw) {
let parsed = raw;
if (typeof raw === 'string') {
try {
parsed = raw.trim().startsWith('{') ? JSON.parse(raw) : { mode: raw };
} catch (_) {
parsed = { mode: raw };
}
}
const source = parsed && typeof parsed === 'object' ? parsed : {};
const normalized = {
mode: source.mode === 'thread' ? 'thread' : 'summary',
threadSource: source.threadSource === 'original' ? 'original' : 'nested',
width: this.numberInRange(source.width, DEFAULT_PREFS.width, 400, 1280),
height: this.numberInRange(source.height, DEFAULT_PREFS.height, 60, 100),
showReadBadges: source.showReadBadges !== false,
showEffectiveBadges: source.showEffectiveBadges !== false,
showCategoryColors: source.showCategoryColors !== false,
forceCreatedOrder: source.forceCreatedOrder !== false,
clickTopicToDrawer: source.clickTopicToDrawer === true,
trackDrawerViews: source.trackDrawerViews === true,
trackDrawerViewsLive: source.trackDrawerViewsLive !== false,
trackDrawerViewsTopicLinks: source.trackDrawerViewsTopicLinks !== false,
trackDrawerViewsReadLater: source.trackDrawerViewsReadLater !== false,
trackDrawerViewsRecent: source.trackDrawerViewsRecent !== false,
trackDrawerViewsResume: source.trackDrawerViewsResume !== false,
trackDrawerViewsFavorites: source.trackDrawerViewsFavorites === true,
autoScrollSpeed: this.autoScrollSpeed(source.autoScrollSpeed),
keywordBlockList: this.cleanTextSetting(source.keywordBlockList),
keywordHighlightList: this.cleanTextSetting(source.keywordHighlightList),
liveEnabled: source.liveEnabled === true,
livePauseHidden: source.livePauseHidden !== false,
liveCreatedOrder: source.liveCreatedOrder !== false,
liveCategoryFilter: this.cleanTextSetting(source.liveCategoryFilter),
prefLeft: this.optionalNumberInRange(source.prefLeft, 0, Math.max(0, window.innerWidth - FLOATING_BUTTON_SIZE)),
prefTop: this.optionalNumberInRange(source.prefTop, 0, Math.max(0, window.innerHeight - FLOATING_BUTTON_SIZE)),
prefEdgeState: this.edgeState(source.prefEdgeState)
};
Object.entries(TUNING_FIELDS).forEach(([field, config]) => {
normalized[field] = this.numberInRange(source[field], DEFAULT_PREFS[field], config.min, config.max);
});
return normalized;
},
numberInRange(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(min, Math.min(max, Math.round(number)));
},
optionalNumberInRange(value, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
return Math.max(min, Math.min(max, Math.round(number)));
},
edgeState(value) {
if (!value || typeof value !== 'object') return null;
const edge = ['left', 'right', 'top', 'bottom'].includes(value.edge) ? value.edge : '';
if (!edge) return null;
const maxLeft = Math.max(0, window.innerWidth - FLOATING_BUTTON_SIZE);
const maxTop = Math.max(0, window.innerHeight - FLOATING_BUTTON_SIZE);
const left = this.optionalNumberInRange(value.left, 0, maxLeft);
const top = this.optionalNumberInRange(value.top, 0, maxTop);
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
return { edge, left, top };
},
cleanTextSetting(value) {
return String(value || '').replace(/\r\n?/g, '\n').trim();
},
mode(mode) {
return mode === 'thread' ? 'thread' : 'summary';
},
threadSource(source) {
return source === 'original' ? 'original' : 'nested';
},
autoScrollSpeed(speed) {
return Object.prototype.hasOwnProperty.call(AUTO_SCROLL_SPEED_LEVELS, speed)
? speed
: AUTO_SCROLL_DEFAULT_SPEED_LEVEL;
}
};
const Urls = {
topicIdFromHref(href) {
if (!href) return '';
try {
const url = new URL(href, location.origin);
const match = url.pathname.match(/\/t\/(?:[^/]+\/)?(\d+)/);
return match ? match[1] : '';
} catch (_) {
return '';
}
},
topicJson(topicId) {
return `/t/${encodeURIComponent(topicId)}.json`;
},
nestedThread(topicId) {
return `/n/topic/${encodeURIComponent(topicId)}`;
},
canonicalTopic(topicId) {
return `/t/${encodeURIComponent(topicId)}`;
},
absolute(raw) {
if (!raw) return '';
if (raw.startsWith('#') || raw.startsWith('mailto:')) return raw;
try {
return new URL(raw, location.origin).href;
} catch (_) {
return raw;
}
},
framePreview(raw) {
const value = String(raw || '').trim();
if (!value) return '';
const normalized = /^[a-z][a-z\d+.-]*:\/\//i.test(value)
? value
: value.startsWith('linux.do/')
? `https://${value}`
: value;
try {
const url = new URL(normalized, location.origin);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
} catch (_) {
return '';
}
}
};
const CreatedOrder = {
started: false,
routeTimer: 0,
apply() {
const nextHref = this.hrefWithCreatedOrder(location.href);
if (!nextHref || nextHref === location.href) return false;
location.replace(nextHref);
return true;
},
start() {
if (this.started) return;
this.started = true;
this.patchHistory();
document.addEventListener('click', (event) => this.rewriteClickedLink(event), true);
window.addEventListener('popstate', () => this.schedule(), { passive: true });
window.addEventListener('hashchange', () => this.schedule(), { passive: true });
},
schedule() {
if (this.routeTimer) return;
this.routeTimer = window.setTimeout(() => {
this.routeTimer = 0;
this.apply();
}, 0);
},
hrefWithCreatedOrder(rawHref) {
if (!Prefs.value.forceCreatedOrder || rawHref === undefined || rawHref === null) return '';
if (typeof rawHref === 'string') {
const value = rawHref.trim();
if (!value || value.startsWith('#') || /^(javascript|mailto|tel):/i.test(value)) return '';
}
try {
const url = new URL(rawHref, location.href);
if (url.origin !== location.origin) return '';
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
if (url.searchParams.get('order') === 'created') return '';
url.searchParams.set('order', 'created');
return url.href;
} catch (_) {
return '';
}
},
rewriteClickedLink(event) {
if (!Prefs.value.forceCreatedOrder) return;
let target = event.target;
if (!(target instanceof Element)) target = target?.parentElement;
const anchor = target?.closest?.('a[href]');
if (!anchor || anchor.hasAttribute('download')) return;
const nextHref = this.hrefWithCreatedOrder(anchor.getAttribute('href'));
if (nextHref && nextHref !== anchor.href) anchor.href = nextHref;
},
patchHistory() {
['pushState', 'replaceState'].forEach((method) => {
const original = history[method];
if (typeof original !== 'function' || original.ldpeekCreatedOrderPatched) return;
const wrapped = (...args) => {
if (args.length >= 3) args[2] = this.historyHref(args[2]);
const result = original.apply(history, args);
this.schedule();
return result;
};
wrapped.ldpeekCreatedOrderPatched = true;
history[method] = wrapped;
});
},
historyHref(rawHref) {
if (rawHref === undefined || rawHref === null) return rawHref;
const nextHref = this.hrefWithCreatedOrder(rawHref);
if (!nextHref) return rawHref;
if (typeof rawHref !== 'string' || /^[a-z][a-z\d+.-]*:/i.test(rawHref)) return nextHref;
const url = new URL(nextHref);
return `${url.pathname}${url.search}${url.hash}`;
}
};
const DiscourseReadTracker = {
prefix: `${NAME}-discourse-track-view-v2:`,
pendingTtl: 30 * 1000,
doneTtl: 8 * 60 * 60 * 1000,
fetchTimeout: 8000,
memoryState: new Map(),
sessionId: '',
markTopic(topicId, href = '', source = 'live-topic') {
const id = String(topicId || '');
if (!id || !Number.isFinite(Number(id))) return Promise.resolve({ accepted: false, skipped: true, reason: 'invalid-topic' });
const info = this.topicInfo(id, href);
if (!info || info.url.origin !== location.origin) return Promise.resolve({ accepted: false, skipped: true, reason: 'cross-origin' });
const recentState = this.readState(info);
if (recentState?.expiresAt && recentState.expiresAt > Date.now()) {
return Promise.resolve(this.recentStateResult(recentState));
}
const token = this.claim(info, source);
if (!token) return Promise.resolve(this.recentStateResult(this.readState(info)));
return this.sendTrackRequest(info, location.href).then((result) => {
if (result.accepted) {
this.markDone(info, token, result);
return result;
}
this.clearStateIfTokenMatches(info, token);
return result;
}).catch((error) => {
this.clearStateIfTokenMatches(info, token);
console.warn(`${LOG_PREFIX} Discourse 浏览追踪上报失败`, error);
throw error;
});
},
recentStateResult(state) {
const fallback = { accepted: true, skipped: true, reason: 'recent-or-pending' };
if (state?.status === 'pending') return fallback;
const result = state?.result;
if (result?.skipped && result.reason === 'starter-post-read-with-browser-pageview') {
return {
...result,
accepted: true,
skipped: true,
reason: 'starter-post-read-with-browser-pageview',
recent: true
};
}
if (result?.confirmed) {
return {
...result,
accepted: true,
confirmed: true,
skipped: true,
reason: 'recent-confirmed-refill',
recent: true
};
}
if (result?.accepted) {
return {
...result,
accepted: true,
skipped: true,
reason: 'recent-or-pending',
recent: true
};
}
return fallback;
},
topicInfo(topicId, href = '') {
try {
const url = new URL(href || Urls.canonicalTopic(topicId), location.origin);
const id = Urls.topicIdFromHref(url.href) || String(topicId || '');
if (!id) return null;
return { topicId: id, url };
} catch (_) {
return null;
}
},
stateKey(info) {
return `${this.prefix}${info.url.hostname}:${info.topicId}`;
},
readState(info) {
const key = this.stateKey(info);
try {
return JSON.parse(window.localStorage.getItem(key) || 'null');
} catch (_) {
return this.memoryState.get(key) || null;
}
},
writeState(info, state) {
const key = this.stateKey(info);
const payload = JSON.stringify(state);
try {
window.localStorage.setItem(key, payload);
} catch (_) {
this.memoryState.set(key, state);
}
},
clearStateIfTokenMatches(info, token) {
const state = this.readState(info);
if (state?.token !== token) return;
const key = this.stateKey(info);
try {
window.localStorage.removeItem(key);
} catch (_) {
// localStorage 可能不可写,内存兜底同步清理。
}
this.memoryState.delete(key);
},
claim(info, source) {
const state = this.readState(info);
if (state?.expiresAt && state.expiresAt > Date.now()) return '';
const token = this.randomId();
this.writeState(info, {
status: 'pending',
token,
source,
createdAt: Date.now(),
expiresAt: Date.now() + this.pendingTtl
});
return token;
},
markDone(info, token, result) {
this.writeState(info, {
status: result.confirmed ? 'confirmed' : 'accepted',
token,
result,
createdAt: Date.now(),
expiresAt: Date.now() + this.doneTtl
});
},
async sendTrackRequest(info, referrerUrl) {
const attempts = [];
let confirmed = false;
let confirmedBy = null;
try {
const topicJson = await this.sendTopicJsonTrack(info);
attempts.push(topicJson);
if (this.isConfirmed(topicJson)) {
confirmed = true;
confirmedBy = 'topic-json-header';
}
if (this.hasStarterPostReadWithBrowserPageView(topicJson)) {
attempts.push({
endpoint: 'topic-read-timing',
ok: true,
skipped: true,
reason: 'starter-post-read-with-browser-pageview',
readState: topicJson.readState
});
return {
confirmed,
accepted: true,
skipped: true,
reason: 'starter-post-read-with-browser-pageview',
confirmedBy: confirmedBy || 'starter-post-read-with-browser-pageview',
attempts
};
}
} catch (error) {
attempts.push({ endpoint: 'topic-json-track', ok: false, error: this.serializeError(error) });
}
try {
attempts.push(await this.sendTopicReadTiming(info));
} catch (error) {
attempts.push({ endpoint: 'topic-read-timing', ok: false, error: this.serializeError(error) });
}
if (confirmed) return { confirmed: true, accepted: true, confirmedBy, attempts };
try {
const sessionTrack = await this.sendSessionCurrentTrack(info, referrerUrl);
attempts.push(sessionTrack);
if (this.isConfirmed(sessionTrack)) {
confirmed = true;
confirmedBy = 'session-current-header';
}
} catch (error) {
attempts.push({ endpoint: 'session-current-fallback', ok: false, error: this.serializeError(error) });
}
const accepted = attempts.some((item) => item?.ok);
return {
confirmed,
accepted,
confirmedBy: confirmedBy || (accepted ? 'http-ok-without-track-header' : null),
attempts
};
},
sendSessionCurrentTrack(info, referrerUrl) {
return this.fetchWithTimeout(`${info.url.origin}${this.basePath()}/session/current.json`, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: this.trackViewHeaders(info, referrerUrl)
}).then((response) => this.responseResult('session-current-track', response));
},
sendTopicJsonTrack(info) {
return this.fetchWithTimeout(`${info.url.origin}${this.basePath()}/t/${encodeURIComponent(info.topicId)}/1.json?track_visit=true&forceLoad=true`, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: this.topicJsonHeaders(info)
}).then((response) => this.topicJsonResponseResult('topic-json-track', response));
},
sendTopicReadTiming(info) {
return this.fetchWithTimeout(`${info.url.origin}${this.basePath()}/topics/timings`, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: this.readTimingHeaders(),
body: this.readTimingBody(info)
}).then((response) => this.responseResult('topic-read-timing', response));
},
commonHeaders() {
const headers = {
Accept: 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest'
};
const token = this.meta('csrf-token');
if (token) headers['X-CSRF-Token'] = token;
return headers;
},
trackViewHeaders(info, referrerUrl) {
return {
...this.commonHeaders(),
'Discourse-Present': 'true',
'Discourse-Track-View': 'true',
'Discourse-Track-View-Topic-Id': String(info.topicId),
'Discourse-Track-View-Url': info.url.href,
'Discourse-Track-View-Referrer': referrerUrl || document.referrer || '',
'Discourse-Track-View-Session-Id': this.trackingSessionId()
};
},
topicJsonHeaders(info) {
return {
...this.commonHeaders(),
'Discourse-Present': 'true',
'Discourse-Track-View': 'true',
'Discourse-Track-View-Topic-Id': String(info.topicId)
};
},
readTimingHeaders() {
return {
...this.commonHeaders(),
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
};
},
readTimingBody(info) {
const body = new URLSearchParams();
body.set('topic_id', String(info.topicId));
body.set('timings[1]', String(this.readTimingMsecs()));
return body;
},
readTimingMsecs() {
return Math.round(2500 + Math.random() * 2500);
},
fetchWithTimeout(url, options) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), this.fetchTimeout);
return fetch(url, { ...options, signal: controller.signal }).finally(() => {
window.clearTimeout(timer);
});
},
responseResult(endpoint, response) {
return {
endpoint,
status: response.status,
ok: response.ok,
trackView: response.headers.get('x-discourse-trackview'),
browserPageView: response.headers.get('x-discourse-browserpageview'),
url: response.url
};
},
async topicJsonResponseResult(endpoint, response) {
const result = this.responseResult(endpoint, response);
if (!response.ok) return result;
try {
const payload = await response.json();
const readState = this.topicJsonReadState(payload);
if (readState) {
result.readState = readState;
result.alreadyRead = readState.alreadyRead;
result.starterPostRead = readState.starterPostRead;
}
} catch (error) {
result.jsonError = this.serializeError(error);
}
return result;
},
topicJsonReadState(payload) {
if (!payload || typeof payload !== 'object') return null;
const highestPostNumber = this.finiteNumber(payload.highest_post_number);
const lastReadPostNumber = this.finiteNumber(payload.last_read_post_number);
const postsCount = this.finiteNumber(payload.posts_count);
const starterPost = this.starterPost(payload);
const starterPostRead = starterPost?.read === true;
const alreadyRead = highestPostNumber > 0 && lastReadPostNumber >= highestPostNumber;
return {
highestPostNumber,
lastReadPostNumber,
postsCount,
starterPostRead,
alreadyRead
};
},
starterPost(payload) {
const posts = Array.isArray(payload?.post_stream?.posts) ? payload.post_stream.posts : [];
return posts.find((post) => Number(post?.post_number) === 1) || posts[0] || null;
},
finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
},
isConfirmed(result) {
if (!result) return false;
if (result.browserPageView === '1' || result.trackView === '1') return true;
if (result.browserPageView === '0' || result.trackView === '0') return false;
return false;
},
hasStarterPostReadWithBrowserPageView(result) {
return result?.endpoint === 'topic-json-track' &&
result?.starterPostRead === true &&
result?.browserPageView === '1';
},
meta(name) {
return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') || '';
},
basePath() {
return this.meta('discourse-base-uri').replace(/\/$/, '');
},
trackingSessionId() {
const metaValue = this.meta('discourse-track-view-session-id');
if (metaValue) return metaValue;
if (this.sessionId) return this.sessionId;
const key = `${this.prefix}session-id`;
try {
this.sessionId = window.sessionStorage.getItem(key) || '';
if (!this.sessionId) {
this.sessionId = this.randomId();
window.sessionStorage.setItem(key, this.sessionId);
}
} catch (_) {
this.sessionId = this.randomId();
}
return this.sessionId;
},
randomId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
},
serializeError(error) {
return {
name: error?.name || 'Error',
message: error?.message || String(error)
};
}
};
const Dom = {
ready(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
} else {
callback();
}
},
make(tag, props = {}, children = []) {
const element = document.createElement(tag);
Object.entries(props).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (key === 'className') element.className = value;
else if (key === 'text') element.textContent = value;
else if (key === 'html') element.innerHTML = value;
else if (key.startsWith('data-')) element.setAttribute(key, value);
else element.setAttribute(key, value);
});
const list = Array.isArray(children) ? children : [children];
list.forEach((child) => {
if (child === undefined || child === null) return;
element.append(child.nodeType ? child : document.createTextNode(String(child)));
});
return element;
},
isOwnSurface(target) {
return !!target?.closest?.(`#${NAME}-eye, #${NAME}-mini-stats, #${NAME}-shade, #${NAME}-drawer, #${NAME}-drawer-sidebar, #${NAME}-prefs`);
}
};
function elementBackgroundLooksDark(element) {
if (!element || !window.getComputedStyle) return null;
const color = window.getComputedStyle(element).backgroundColor;
const match = color?.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/i);
if (!match) return null;
const alphaRaw = match[4];
const alpha = alphaRaw
? (alphaRaw.endsWith('%') ? Number.parseFloat(alphaRaw) / 100 : Number.parseFloat(alphaRaw))
: 1;
if (!Number.isFinite(alpha) || alpha <= 0.05) return null;
const r = Number.parseFloat(match[1]);
const g = Number.parseFloat(match[2]);
const b = Number.parseFloat(match[3]);
if (![r, g, b].every(Number.isFinite)) return null;
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance < 0.52;
}
function pagePrefersDarkTheme() {
const root = document.documentElement;
const body = document.body;
const tokens = [
...Array.from(root?.classList || []),
...Array.from(body?.classList || [])
].map((token) => token.toLowerCase());
const darkTokens = new Set(['dark', 'dark-scheme', 'theme-dark', 'dark-mode', 'color-scheme-dark']);
const lightTokens = new Set(['light', 'light-scheme', 'theme-light', 'light-mode', 'color-scheme-light']);
if (tokens.some((token) => darkTokens.has(token))) return true;
if (tokens.some((token) => lightTokens.has(token))) return false;
const schemes = [
root?.style?.colorScheme,
body?.style?.colorScheme,
root ? window.getComputedStyle?.(root)?.colorScheme : '',
body ? window.getComputedStyle?.(body)?.colorScheme : ''
].filter(Boolean);
const explicitScheme = schemes.find((scheme) => /\bdark\b/i.test(scheme) !== /\blight\b/i.test(scheme));
if (explicitScheme) return /\bdark\b/i.test(explicitScheme);
const backgroundDark = [body, root]
.map((element) => elementBackgroundLooksDark(element))
.find((value) => value !== null);
if (backgroundDark !== undefined) return backgroundDark;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches || false;
}
function estimateSerializedBytes(value) {
try {
const seen = new WeakSet();
const json = JSON.stringify(value, (_, next) => {
if (typeof next === 'bigint') return String(next);
if (typeof next === 'function') return `[Function ${next.name || 'anonymous'}]`;
if (next instanceof Map) return Array.from(next.entries());
if (next instanceof Set) return Array.from(next.values());
if (typeof Promise !== 'undefined' && next instanceof Promise) return '[Promise]';
if (typeof Element !== 'undefined' && next instanceof Element) return `[Element ${next.tagName.toLowerCase()}]`;
if (typeof Node !== 'undefined' && next instanceof Node) return `[Node ${next.nodeName}]`;
if (next && typeof next === 'object') {
if (seen.has(next)) return '[Circular]';
seen.add(next);
}
return next;
});
return json ? json.length * 2 : 0;
} catch (_) {
return 0;
}
}
function formatBytes(bytes) {
const number = Number(bytes);
if (!Number.isFinite(number) || number < 0) return '未知';
const units = ['B', 'KB', 'MB', 'GB'];
let value = number;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
const digits = value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(digits)} ${units[index]}`;
}
// 复用的点击式尺寸控件,抽屉内设置和页面悬浮设置共用同一套行为。
const SizeControls = {
config(field) {
if (field === 'height') return { min: 60, max: 100, step: 5, unit: 'vh' };
return { min: 400, max: 1280, step: 20, unit: 'px' };
},
build(field, label) {
const config = this.config(field);
const value = Prefs.value[field];
return Dom.make('div', { className: `${NAME}-size-row`, 'data-size-row': field }, [
Dom.make('span', { className: `${NAME}-size-label`, text: label }),
Dom.make('button', {
className: `${NAME}-size-step`,
type: 'button',
title: `减少${label}`,
'aria-label': `减少${label}`,
'data-size-step': '-1',
'data-size-field': field,
text: '-'
}),
Dom.make('button', {
className: `${NAME}-size-clicker`,
type: 'button',
role: 'slider',
'aria-label': label,
'aria-valuemin': String(config.min),
'aria-valuemax': String(config.max),
'aria-valuenow': String(value),
'data-size-track': field
}, [
Dom.make('span', { className: `${NAME}-size-fill`, 'data-size-fill': field }),
Dom.make('span', { className: `${NAME}-size-thumb`, 'data-size-thumb': field })
]),
Dom.make('button', {
className: `${NAME}-size-step`,
type: 'button',
title: `增加${label}`,
'aria-label': `增加${label}`,
'data-size-step': '1',
'data-size-field': field,
text: '+'
}),
Dom.make('input', {
className: `${NAME}-size-number`,
type: 'number',
min: String(config.min),
max: String(config.max),
step: String(config.step),
value: String(value),
'data-size-input': field
}),
Dom.make('span', { className: `${NAME}-size-unit`, text: config.unit })
]);
},
clamp(field, value) {
const config = this.config(field);
return Prefs.numberInRange(value, DEFAULT_PREFS[field], config.min, config.max);
},
fromTrack(track, clientX) {
const field = track.dataset.sizeTrack;
if (field !== 'width' && field !== 'height') return null;
const config = this.config(field);
const rect = track.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
const raw = config.min + Math.max(0, Math.min(1, ratio)) * (config.max - config.min);
const stepped = Math.round(raw / config.step) * config.step;
return { field, value: this.clamp(field, stepped) };
},
fromStep(button) {
const field = button.dataset.sizeField;
if (field !== 'width' && field !== 'height') return null;
const config = this.config(field);
const direction = button.dataset.sizeStep === '-1' ? -1 : 1;
return { field, value: this.clamp(field, Prefs.value[field] + direction * config.step) };
},
fromInput(input) {
const field = input.dataset.sizeInput;
if (field !== 'width' && field !== 'height') return null;
return { field, value: this.clamp(field, input.value) };
},
fromTyping(input) {
const field = input.dataset.sizeInput;
if (field !== 'width' && field !== 'height') return null;
if (input.value.trim() === '') return null;
const value = Number(input.value);
const config = this.config(field);
if (!Number.isFinite(value) || value < config.min || value > config.max) return null;
return { field, value: this.clamp(field, value) };
},
sync(root) {
if (!root) return;
root.querySelectorAll('[data-size-input]').forEach((input) => {
const field = input.dataset.sizeInput;
input.value = String(Prefs.value[field]);
});
root.querySelectorAll('[data-size-track]').forEach((track) => {
const field = track.dataset.sizeTrack;
const config = this.config(field);
const value = Prefs.value[field];
const ratio = (value - config.min) / (config.max - config.min);
const percent = `${Math.max(0, Math.min(1, ratio)) * 100}%`;
track.style.setProperty('--size-ratio', percent);
track.setAttribute('aria-valuenow', String(value));
});
}
};
// 数据 tab 中的运行参数设置。这里保留较小上限,避免缓存和历史过大再次造成内存压力。
const TuningControls = {
config(field) {
return TUNING_FIELDS[field] || null;
},
build(field) {
const config = this.config(field);
if (!config) return null;
return Dom.make('div', { className: `${NAME}-tuning-row`, 'data-tuning-row': field }, [
Dom.make('div', { className: `${NAME}-tuning-copy` }, [
Dom.make('span', { className: `${NAME}-tuning-label`, text: config.label }),
Dom.make('span', { className: `${NAME}-tuning-help`, text: config.help })
]),
Dom.make('button', {
className: `${NAME}-tuning-step`,
type: 'button',
title: `减少${config.label}`,
'aria-label': `减少${config.label}`,
'data-tuning-step': '-1',
'data-tuning-field': field,
text: '-'
}),
Dom.make('input', {
className: `${NAME}-tuning-number`,
type: 'number',
min: String(config.min),
max: String(config.max),
step: String(config.step),
value: String(Prefs.value[field]),
'data-tuning-input': field
}),
Dom.make('button', {
className: `${NAME}-tuning-step`,
type: 'button',
title: `增加${config.label}`,
'aria-label': `增加${config.label}`,
'data-tuning-step': '1',
'data-tuning-field': field,
text: '+'
}),
Dom.make('span', { className: `${NAME}-tuning-unit`, text: config.unit })
]);
},
clamp(field, value) {
const config = this.config(field);
if (!config) return null;
return Prefs.numberInRange(value, DEFAULT_PREFS[field], config.min, config.max);
},
fromStep(button) {
const field = button.dataset.tuningField;
const config = this.config(field);
if (!config) return null;
const direction = button.dataset.tuningStep === '-1' ? -1 : 1;
return { field, value: this.clamp(field, Prefs.value[field] + direction * config.step) };
},
fromInput(input) {
const field = input.dataset.tuningInput;
const value = this.clamp(field, input.value);
return value === null ? null : { field, value };
},
fromTyping(input) {
const field = input.dataset.tuningInput;
const config = this.config(field);
if (!config || input.value.trim() === '') return null;
const value = Number(input.value);
if (!Number.isFinite(value) || value < config.min || value > config.max) return null;
return { field, value: this.clamp(field, value) };
},
sync(root) {
if (!root) return;
root.querySelectorAll('[data-tuning-input]').forEach((input) => {
const field = input.dataset.tuningInput;
if (this.config(field)) input.value = String(Prefs.value[field]);
});
}
};
const TopicHitTest = {
fromPointerTarget(target) {
if (!(target instanceof Element) || Dom.isOwnSurface(target)) return null;
const anchor = target.closest(TOPIC_ANCHOR_QUERY);
if (!anchor) return null;
const topicId = Urls.topicIdFromHref(anchor.getAttribute('href'));
return topicId ? { topicId, anchor } : null;
}
};
// 话题收藏只保存在本地,方便从悬浮设置面板快速回到常看的话题。
const Favorites = {
items: [],
load() {
let raw = null;
try {
raw = typeof GM_getValue === 'function'
? GM_getValue(FAVORITES_KEY, null)
: window.localStorage.getItem(FAVORITES_KEY);
} catch (_) {
raw = null;
}
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
this.items = Array.isArray(parsed)
? parsed.map((item) => this.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.favoriteLimit)
: [];
} catch (_) {
this.items = [];
}
return this.items;
},
save() {
const payload = JSON.stringify(this.items.slice(0, Prefs.value.favoriteLimit));
try {
if (typeof GM_setValue === 'function') {
GM_setValue(FAVORITES_KEY, payload);
} else {
window.localStorage.setItem(FAVORITES_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 收藏保存失败`, error);
}
},
normalizeItem(item) {
const id = String(item?.id || '');
if (!id) return null;
const title = this.cleanTitle(item?.title) || `话题 ${id}`;
return {
id,
title,
href: Urls.framePreview(item?.href) || Urls.absolute(Urls.canonicalTopic(id)),
at: Number.isFinite(Number(item?.at)) ? Number(item.at) : Date.now()
};
},
cleanTitle(value) {
return String(value || '')
.replace(/\s+/g, ' ')
.replace(/\s[-–—]\s*(Linux\.?DO|Linux\.do).*$/i, '')
.trim();
},
titleFromAnchor(anchor, topicId) {
if (!anchor) return '';
const titleAnchor = this.topicTitleAnchor(anchor, topicId);
const title = this.cleanTitle(this.anchorTitleText(titleAnchor));
if (title && !this.isWeakTitle(title, topicId)) return title;
if (title === '新话题') return title;
const fallbackTitle = this.cleanTitle(this.anchorTitleText(anchor));
if (fallbackTitle && !this.isWeakTitle(fallbackTitle, topicId)) return fallbackTitle;
return fallbackTitle === '新话题' ? fallbackTitle : `话题 ${topicId}`;
},
topicTitleAnchor(anchor, topicId) {
if (!anchor) return null;
const id = String(topicId || Urls.topicIdFromHref(anchor.getAttribute?.('href')) || '');
const anchorTitle = this.cleanTitle(this.anchorTitleText(anchor));
if (this.isTopicTitleAnchor(anchor, id) && !this.isWeakTitle(anchorTitle, id)) return anchor;
const row = this.topicRow(anchor);
const titleAnchor = this.findTitleAnchor(row, id, anchor);
if (titleAnchor) return titleAnchor;
return anchor;
},
findTitleAnchor(scope, topicId, sourceAnchor = null) {
const id = String(topicId || '');
if (!scope || !id) return null;
const primary = Array.from(scope.querySelectorAll?.(BADGE_ANCHOR_QUERY) || []).find((candidate) => {
return candidate !== sourceAnchor &&
this.isTopicTitleAnchor(candidate, id) &&
!this.isWeakTitle(this.anchorTitleText(candidate), id);
});
if (primary) return primary;
return Array.from(scope.querySelectorAll?.(TOPIC_ANCHOR_QUERY) || []).find((candidate) => {
return candidate !== sourceAnchor &&
Urls.topicIdFromHref(candidate.getAttribute('href')) === id &&
!this.isWeakTitle(this.anchorTitleText(candidate), id) &&
!this.isNewTopicBadgeAnchor(candidate);
}) || null;
},
isTopicTitleAnchor(anchor, topicId) {
if (!anchor?.matches?.(BADGE_ANCHOR_QUERY)) return false;
return Urls.topicIdFromHref(anchor.getAttribute('href')) === String(topicId || '');
},
anchorTitleText(anchor) {
const titleNode = anchor?.querySelector?.('span[dir="auto"]');
return titleNode?.textContent || anchor?.getAttribute?.('title') || anchor?.textContent || '';
},
isNewTopicBadgeAnchor(anchor) {
return !!anchor?.matches?.('a.badge.badge-notification.new-topic[title="新话题"][href*="/t/"]');
},
topicRow(anchor) {
return anchor?.closest?.([
'tr.topic-list-item',
'.topic-list-item',
'.latest-topic-list-item',
'.search-result-topic',
'.fps-result',
'.suggested-topics-list-item',
'li',
'article'
].join(',')) || anchor?.parentElement || null;
},
isWeakTitle(value, topicId) {
const title = this.cleanTitle(value);
return !title ||
title === '新话题' ||
title === `话题 ${topicId}` ||
/^[\d.,,]+$/.test(title);
},
bestTitle(topicId, title = '', sourceAnchor = null, existingTitle = '') {
const id = String(topicId || '');
const explicitTitle = this.cleanTitle(title);
if (explicitTitle) return explicitTitle;
const anchorTitle = this.titleFromAnchor(sourceAnchor, id);
if (!this.isWeakTitle(anchorTitle, id)) return anchorTitle;
const savedTitle = this.cleanTitle(existingTitle);
if (!this.isWeakTitle(savedTitle, id)) return savedTitle;
return anchorTitle === '新话题' ? anchorTitle : `话题 ${id}`;
},
titleFromDocument(doc, topicId) {
if (!doc) return '';
const titleNode = doc.querySelector?.([
'h1 a.fancy-title',
'h1 .fancy-title',
'h1',
'.topic-title h1',
'.title-wrapper h1'
].join(','));
return this.cleanTitle(titleNode?.textContent || doc.title) || `话题 ${topicId}`;
},
hrefFrom(topicId, sourceAnchor, sourceHref) {
const id = String(topicId || '');
const titleAnchor = this.topicTitleAnchor(sourceAnchor, id);
const titleHref = titleAnchor && titleAnchor !== sourceAnchor ? titleAnchor.getAttribute?.('href') : '';
return Urls.framePreview(titleHref || sourceHref || sourceAnchor?.getAttribute?.('href') || Urls.canonicalTopic(id)) ||
Urls.absolute(Urls.canonicalTopic(id));
},
itemFrom(topicId, sourceAnchor, sourceHref, title = '') {
const id = String(topicId || '');
if (!id) return null;
const existing = this.get(id);
return {
id,
title: this.bestTitle(id, title, sourceAnchor, existing?.title),
href: this.hrefFrom(id, sourceAnchor, sourceHref || existing?.href || ''),
at: Date.now()
};
},
updateMeta(topicId, sourceAnchor, sourceHref, title = '') {
const id = String(topicId || '');
const index = this.items.findIndex((item) => item.id === id);
if (index < 0) return;
const next = this.itemFrom(id, sourceAnchor, sourceHref, title);
if (!next) return;
if (this.items[index].title === next.title && this.items[index].href === next.href) return;
this.items[index] = { ...this.items[index], ...next, at: this.items[index].at };
this.save();
},
toggle(topicId, sourceAnchor, sourceHref, title = '') {
const id = String(topicId || '');
if (!id) return { active: false, item: null };
const existing = this.get(id);
if (existing) {
this.remove(id);
return { active: false, item: existing };
}
const item = this.itemFrom(id, sourceAnchor, sourceHref, title);
if (!item) return { active: false, item: null };
this.items = [item, ...this.items.filter((candidate) => candidate.id !== id)].slice(0, Prefs.value.favoriteLimit);
this.save();
return { active: true, item };
},
trimToLimit() {
const next = this.items.slice(0, Prefs.value.favoriteLimit);
if (next.length === this.items.length) return;
this.items = next;
this.save();
},
remove(topicId) {
const id = String(topicId || '');
const length = this.items.length;
this.items = this.items.filter((item) => item.id !== id);
if (this.items.length !== length) this.save();
},
get(topicId) {
const id = String(topicId || '');
return this.items.find((item) => item.id === id) || null;
},
has(topicId) {
return !!this.get(topicId);
}
};
const ReadLaterQueue = {
items: [],
load() {
let raw = null;
try {
raw = typeof GM_getValue === 'function'
? GM_getValue(READ_LATER_KEY, null)
: window.localStorage.getItem(READ_LATER_KEY);
} catch (_) {
raw = null;
}
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
this.items = Array.isArray(parsed)
? parsed.map((item) => this.normalizeItem(item)).filter(Boolean).slice(0, MAX_READ_LATER)
: [];
} catch (_) {
this.items = [];
}
return this.items;
},
save() {
const payload = JSON.stringify(this.items.slice(0, MAX_READ_LATER));
try {
if (typeof GM_setValue === 'function') {
GM_setValue(READ_LATER_KEY, payload);
} else {
window.localStorage.setItem(READ_LATER_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 稍后阅读队列保存失败`, error);
}
},
normalizeItem(item) {
const id = String(item?.id || '');
if (!id) return null;
return {
id,
title: Favorites.cleanTitle(item?.title) || `话题 ${id}`,
href: Urls.framePreview(item?.href) || Urls.absolute(Urls.canonicalTopic(id)),
at: Number.isFinite(Number(item?.at)) ? Number(item.at) : Date.now()
};
},
itemFrom(topicId, sourceAnchor = null, sourceHref = '', title = '') {
const id = String(topicId || '');
if (!id) return null;
const existing = this.get(id) || Favorites.get(id) || ReadMemory.get(id);
return {
id,
title: Favorites.bestTitle(id, title, sourceAnchor, existing?.title),
href: Favorites.hrefFrom(id, sourceAnchor, sourceHref || existing?.href || ''),
at: Date.now()
};
},
add(topicId, sourceAnchor = null, sourceHref = '', title = '') {
const item = this.itemFrom(topicId, sourceAnchor, sourceHref, title);
if (!item) return { added: false, existing: false, full: false, item: null };
const index = this.items.findIndex((candidate) => candidate.id === item.id);
if (index >= 0) {
this.items[index] = { ...this.items[index], ...item, at: this.items[index].at };
this.save();
return { added: false, existing: true, full: false, item: this.items[index] };
}
if (this.items.length >= MAX_READ_LATER) {
return { added: false, existing: false, full: true, item };
}
this.items = [...this.items, item];
this.save();
return { added: true, existing: false, full: false, item };
},
remove(topicId) {
const id = String(topicId || '');
const length = this.items.length;
this.items = this.items.filter((item) => item.id !== id);
const changed = this.items.length !== length;
if (changed) this.save();
return changed;
},
clear() {
if (!this.items.length) return false;
this.items = [];
this.save();
return true;
},
get(topicId) {
const id = String(topicId || '');
return this.items.find((item) => item.id === id) || null;
},
has(topicId) {
return !!this.get(topicId);
}
};
const PageNav = {
items: [],
started: false,
syncTimer: 0,
load() {
let raw = null;
let hasStored = false;
try {
raw = typeof GM_getValue === 'function'
? GM_getValue(PAGE_NAV_KEY, null)
: window.localStorage.getItem(PAGE_NAV_KEY);
hasStored = raw !== null && raw !== undefined && raw !== '';
} catch (_) {
raw = null;
hasStored = false;
}
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
this.items = Array.isArray(parsed)
? parsed.map((item) => this.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.pageNavLimit)
: [];
} catch (_) {
this.items = [];
}
if (!hasStored) {
this.items = this.defaultItems();
this.save();
}
return this.items;
},
save() {
const payload = JSON.stringify(this.items.slice(0, Prefs.value.pageNavLimit));
try {
if (typeof GM_setValue === 'function') {
GM_setValue(PAGE_NAV_KEY, payload);
} else {
window.localStorage.setItem(PAGE_NAV_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 页面导航保存失败`, error);
}
},
start() {
if (this.started) return;
this.started = true;
this.patchHistory();
window.addEventListener('popstate', () => this.scheduleSync(), { passive: true });
window.addEventListener('hashchange', () => this.scheduleSync(), { passive: true });
window.addEventListener('focus', () => {
this.load();
this.scheduleSync();
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.load();
this.scheduleSync();
}
});
},
patchHistory() {
['pushState', 'replaceState'].forEach((method) => {
const original = window.history?.[method];
if (typeof original !== 'function') return;
const nav = this;
try {
window.history[method] = function patchedHistoryMethod(...args) {
const result = original.apply(this, args);
nav.scheduleSync();
return result;
};
} catch (_) {
// 历史 API 只用于同步当前标签高亮,包装失败不影响核心预览能力。
}
});
},
scheduleSync() {
if (this.syncTimer) window.clearTimeout(this.syncTimer);
this.syncTimer = window.setTimeout(() => {
this.syncTimer = 0;
this.syncSurfaces();
}, 120);
},
defaultItems() {
return [
{ title: '首页', href: '/' },
{ title: '最新', href: '/latest' },
{ title: '热门', href: '/top' },
{ title: '新话题', href: '/new' },
{ title: '未读', href: '/unread' },
{ title: '分类', href: '/categories' }
].map((item) => this.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.pageNavLimit);
},
normalizeItem(item) {
const href = this.normalizeHref(item?.href);
if (!href) return null;
const id = this.idFromHref(href);
const now = Date.now();
return {
id,
title: this.cleanTitle(item?.title) || this.titleFromHref(href),
href,
at: Number.isFinite(Number(item?.at)) ? Number(item.at) : now,
updatedAt: Number.isFinite(Number(item?.updatedAt)) ? Number(item.updatedAt) : now
};
},
normalizeHref(raw) {
const href = Urls.framePreview(raw);
if (!href) return '';
try {
const url = new URL(href, location.origin);
if (url.origin !== location.origin) return '';
url.hash = '';
return url.href;
} catch (_) {
return '';
}
},
idFromHref(href) {
try {
const url = new URL(href, location.origin);
return `${url.pathname || '/'}${url.search || ''}`;
} catch (_) {
return String(href || '');
}
},
currentId() {
return this.idFromHref(this.normalizeHref(location.href));
},
cleanTitle(value) {
return Favorites.cleanTitle(value)
.replace(/^\|+|\|+$/g, '')
.trim();
},
titleFromDocument(href) {
const title = this.cleanTitle(
document.querySelector('h1 a.fancy-title, h1 .fancy-title, h1, .topic-title h1')?.textContent ||
document.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
document.title
);
return title || this.titleFromHref(href);
},
titleFromHref(href) {
try {
const url = new URL(href, location.origin);
const topicId = Urls.topicIdFromHref(url.href);
if (topicId) return `话题 ${topicId}`;
const parts = url.pathname.split('/').filter(Boolean).map((part) => {
try {
return decodeURIComponent(part);
} catch (_) {
return part;
}
});
if (!parts.length) return '首页';
const known = {
latest: '最新',
new: '新话题',
unread: '未读',
top: '热门',
categories: '分类',
c: '分类',
u: '用户',
search: '搜索'
};
return known[parts[0]] || parts.join(' / ');
} catch (_) {
return 'LinuxDO 页面';
}
},
currentItem() {
const href = this.normalizeHref(location.href);
if (!href) return null;
return {
id: this.idFromHref(href),
title: this.titleFromDocument(href),
href,
at: Date.now(),
updatedAt: Date.now()
};
},
add(item) {
const nextItem = this.normalizeItem(item);
if (!nextItem) return { added: false, existing: false, item: null };
const existing = this.get(nextItem.id);
const next = {
...existing,
...nextItem,
at: existing?.at || nextItem.at,
updatedAt: Date.now()
};
this.items = [next, ...this.items.filter((candidate) => candidate.id !== next.id)].slice(0, Prefs.value.pageNavLimit);
this.save();
this.syncSurfaces();
return { added: !existing, existing: !!existing, item: next };
},
addCurrent() {
const item = this.currentItem();
if (!item) return null;
return this.add(item);
},
displayHref(href) {
try {
const url = new URL(href, location.origin);
return url.origin === location.origin
? `${url.pathname}${url.search}`
: url.href;
} catch (_) {
return String(href || '');
}
},
update(id, next) {
const currentId = String(id || '');
const current = this.get(currentId);
if (!current) return { updated: false, item: null, reason: 'missing' };
const href = this.normalizeHref(next?.href || current.href);
if (!href) return { updated: false, item: current, reason: 'invalid-url' };
const item = this.normalizeItem({
...current,
href,
title: this.cleanTitle(next?.title) || this.titleFromHref(href),
updatedAt: Date.now()
});
if (!item) return { updated: false, item: current, reason: 'invalid-url' };
const titleChanged = current.title !== item.title;
const hrefChanged = current.href !== item.href;
if (!titleChanged && !hrefChanged) return {
updated: false,
item: current,
reason: 'unchanged',
titleChanged: false,
hrefChanged: false
};
const index = this.items.findIndex((candidate) => candidate.id === currentId);
const nextItems = this.items.filter((candidate) => candidate.id !== currentId && candidate.id !== item.id);
nextItems.splice(Math.max(0, index), 0, {
...item,
at: current.at || item.at,
updatedAt: Date.now()
});
this.items = nextItems.slice(0, Prefs.value.pageNavLimit);
this.save();
this.syncSurfaces();
return { updated: true, item: this.get(item.id) || item, titleChanged, hrefChanged };
},
editWithPrompt(id) {
const item = this.get(id);
if (!item) return { updated: false, item: null, reason: 'missing' };
const title = window.prompt('修改导航标题(确定后继续修改地址;留空则按地址自动命名)', item.title);
if (title === null) return { updated: false, item, cancelled: true };
const href = window.prompt('修改导航地址(支持 /latest 或完整 LinuxDO 链接)', this.displayHref(item.href));
if (href === null) return { updated: false, item, cancelled: true };
return this.update(id, { title, href });
},
editResultMessage(result) {
if (result?.updated) {
if (result.titleChanged && result.hrefChanged) return '已更新导航标题和地址';
if (result.hrefChanged) return '已更新导航地址';
return '已更新导航标题';
}
if (result?.reason === 'unchanged') return '导航标签未变化';
return '导航地址无效';
},
restoreDefaults() {
this.items = this.defaultItems();
this.save();
this.syncSurfaces();
return this.items.length;
},
syncSurfaces() {
if (typeof FloatingPrefs !== 'undefined') FloatingPrefs.sync();
if (typeof Drawer !== 'undefined') Drawer.renderSidebar();
},
trimToLimit() {
const next = this.items.slice(0, Prefs.value.pageNavLimit);
if (next.length === this.items.length) return;
this.items = next;
this.save();
this.syncSurfaces();
},
get(id) {
const value = String(id || '');
return this.items.find((item) => item.id === value) || null;
},
isCurrent(itemOrId) {
const id = typeof itemOrId === 'object' ? itemOrId?.id : itemOrId;
return String(id || '') === this.currentId();
},
neighbor(id) {
const index = this.items.findIndex((item) => item.id === String(id || ''));
if (index < 0) return null;
return this.items[index + 1] || this.items[index - 1] || null;
},
open(id, options = {}) {
const item = this.get(id);
if (!item) return false;
if (options.newTab) {
const opened = window.open(item.href, '_blank', 'noopener,noreferrer');
if (opened) opened.opener = null;
return true;
}
if (this.isCurrent(item)) {
showToast('已在当前页面');
return true;
}
window.location.assign(item.href);
return true;
},
remove(id) {
const value = String(id || '');
const length = this.items.length;
this.items = this.items.filter((item) => item.id !== value);
if (this.items.length === length) return false;
this.save();
this.syncSurfaces();
return true;
},
clear() {
if (!this.items.length) return false;
this.items = [];
this.save();
this.syncSurfaces();
return true;
}
};
// 保存可配置数量的预览过话题。“已读”表示被脚本预览过;
// “话题已生效”只表示曾观察到 new-topic 小蓝点,并且该小蓝点之后消失了。
const ReadMemory = {
items: [],
revision: 0,
load() {
let raw = null;
try {
raw = typeof GM_getValue === 'function'
? GM_getValue(MEMORY_KEY, null)
: window.localStorage.getItem(MEMORY_KEY);
} catch (_) {
raw = null;
}
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
this.items = Array.isArray(parsed)
? parsed.map((item) => this.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.memoryLimit)
: [];
} catch (_) {
this.items = [];
}
this.touch();
return this.items;
},
save() {
const payload = JSON.stringify(this.items.slice(0, Prefs.value.memoryLimit));
try {
if (typeof GM_setValue === 'function') {
GM_setValue(MEMORY_KEY, payload);
} else {
window.localStorage.setItem(MEMORY_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 阅读记忆保存失败`, error);
}
this.touch();
},
touch() {
this.revision += 1;
},
normalizeItem(item) {
const id = String(item?.id || '');
if (!id) return null;
return {
id,
title: Favorites.cleanTitle(item?.title) || `话题 ${id}`,
href: Urls.framePreview(item?.href) || Urls.absolute(Urls.canonicalTopic(id)),
at: Number.isFinite(Number(item?.at)) ? Number(item.at) : Date.now(),
readAt: Number.isFinite(Number(item?.readAt)) ? Number(item.readAt) : Date.now(),
trackedNewTopic: item?.trackedNewTopic === true,
effective: item?.effective === true,
effectiveAt: Number.isFinite(Number(item?.effectiveAt)) ? Number(item.effectiveAt) : undefined
};
},
remember(topicId, sourceAnchor = null, sourceHref = '', title = '', options = {}) {
const id = String(topicId || '');
if (!id) return null;
const existing = this.items.find((item) => item.id === id);
const sourceRow = sourceAnchor instanceof Element ? TopicBadges.topicRow(sourceAnchor) : null;
const hasNewTopicBadge = TopicBadges.hasNewTopicBadge(id, sourceRow);
const next = {
id,
title: Favorites.bestTitle(id, title, sourceAnchor, existing?.title),
href: Favorites.hrefFrom(id, sourceAnchor, sourceHref || existing?.href || ''),
at: Date.now(),
readAt: Date.now(),
trackedNewTopic: existing?.trackedNewTopic === true || hasNewTopicBadge,
effective: existing?.effective === true && existing?.trackedNewTopic === true && !hasNewTopicBadge,
effectiveAt: existing?.effective === true && !hasNewTopicBadge ? existing?.effectiveAt : undefined
};
if (options.preserveOrder && existing) {
this.items = this.items
.map((item) => (item.id === id ? next : item))
.slice(0, Prefs.value.memoryLimit);
} else {
this.items = [next, ...this.items.filter((item) => item.id !== id)].slice(0, Prefs.value.memoryLimit);
}
this.save();
TopicBadges.start();
TopicBadges.refreshTopic(id, sourceAnchor);
if (next.trackedNewTopic && !next.effective) TopicBadges.watchEffect(id);
return next;
},
updateMeta(topicId, sourceAnchor = null, sourceHref = '', title = '') {
const id = String(topicId || '');
const index = this.items.findIndex((item) => item.id === id);
if (index < 0) return;
const nextTitle = Favorites.bestTitle(id, title, sourceAnchor, this.items[index].title);
const nextHref = Favorites.hrefFrom(id, sourceAnchor, sourceHref || this.items[index].href || '');
if (this.items[index].title === nextTitle && this.items[index].href === nextHref) return;
this.items[index] = { ...this.items[index], title: nextTitle, href: nextHref };
this.save();
},
clear() {
this.items = [];
this.save();
TopicBadges.refresh();
},
clearOlderThan(days) {
const cutoff = Date.now() - Math.max(1, Number(days) || 1) * 24 * 60 * 60 * 1000;
const before = this.items.length;
this.items = this.items.filter((item) => {
const readAt = Number(item.readAt || item.at);
return Number.isFinite(readAt) && readAt >= cutoff;
});
const removed = before - this.items.length;
if (removed) {
this.save();
TopicBadges.refresh();
}
return removed;
},
trimToLimit() {
const next = this.items.slice(0, Prefs.value.memoryLimit);
if (next.length === this.items.length) return false;
this.items = next;
this.save();
TopicBadges.refresh();
return true;
},
markTrackedNewTopic(topicId) {
const id = String(topicId || '');
if (!id) return;
let changed = false;
this.items = this.items.map((item) => {
if (item.id !== id || item.trackedNewTopic) return item;
changed = true;
return { ...item, trackedNewTopic: true };
});
if (changed) this.save();
},
markEffective(topicId) {
const id = String(topicId || '');
if (!id) return;
let changed = false;
this.items = this.items.map((item) => {
if (item.id !== id || item.effective) return item;
changed = true;
return { ...item, effective: true, effectiveAt: Date.now() };
});
if (changed) this.save();
},
get(topicId) {
const id = String(topicId || '');
return this.items.find((item) => item.id === id) || null;
},
has(topicId) {
return !!this.get(topicId);
}
};
const DrawerState = {
value: null,
load() {
let raw = null;
try {
raw = typeof GM_getValue === 'function'
? GM_getValue(DRAWER_STATE_KEY, null)
: window.localStorage.getItem(DRAWER_STATE_KEY);
} catch (_) {
raw = null;
}
try {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
this.value = this.normalize(parsed);
} catch (_) {
this.value = null;
}
return this.value;
},
save(next) {
const sameTopic = this.value?.topicId && this.value.topicId === String(next?.topicId || '');
const base = sameTopic ? this.value : {};
const normalized = this.normalize({ ...base, ...next, updatedAt: Date.now() });
if (!normalized) return null;
this.value = normalized;
try {
const payload = JSON.stringify(normalized);
if (typeof GM_setValue === 'function') {
GM_setValue(DRAWER_STATE_KEY, payload);
} else {
window.localStorage.setItem(DRAWER_STATE_KEY, payload);
}
} catch (error) {
console.warn(`${LOG_PREFIX} 抽屉状态保存失败`, error);
}
return this.value;
},
clear() {
this.value = null;
try {
if (typeof GM_setValue === 'function') {
GM_setValue(DRAWER_STATE_KEY, '');
} else {
window.localStorage.removeItem(DRAWER_STATE_KEY);
}
} catch (_) {
// 清理失败不影响当前会话。
}
},
normalize(raw) {
const source = raw && typeof raw === 'object' ? raw : null;
const topicId = String(source?.topicId || '');
if (!topicId) return null;
const history = Array.isArray(source.frameHistory)
? source.frameHistory.map((href) => Urls.framePreview(href)).filter(Boolean).slice(-Prefs.value.frameHistoryLimit)
: [];
const index = Prefs.numberInRange(source.frameHistoryIndex, Math.max(0, history.length - 1), 0, Math.max(0, history.length - 1));
const frameUrl = Urls.framePreview(source.frameUrl) || history[index] || '';
return {
topicId,
title: Favorites.cleanTitle(source.title) || `话题 ${topicId}`,
sourceHref: Urls.framePreview(source.sourceHref) || Urls.absolute(Urls.canonicalTopic(topicId)),
mode: Prefs.mode(source.mode),
frameUrl,
frameHistory: history,
frameHistoryIndex: index,
updatedAt: Number.isFinite(Number(source.updatedAt)) ? Number(source.updatedAt) : Date.now()
};
}
};
// 同步话题标题旁的状态标记。滚动时不做全量扫描,只处理新增话题行。
const TopicBadges = {
started: false,
refreshTimer: 0,
observer: null,
pendingAnchors: new Set(),
pendingTimer: 0,
pendingBatchSize: 80,
newTopicIds: new Set(),
newTopicIndexReady: false,
watchingTopics: new Set(),
start() {
if (this.started) return;
this.started = true;
this.refresh();
window.addEventListener('focus', () => this.schedule(), { passive: true });
window.addEventListener('pageshow', () => this.schedule(), { passive: true });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) this.schedule();
});
this.observeMutations();
},
schedule() {
if (this.refreshTimer) return;
this.refreshTimer = window.setTimeout(() => {
this.refreshTimer = 0;
this.refresh();
}, Prefs.value.badgeRefreshDelay);
},
observeMutations() {
if (this.observer || typeof MutationObserver !== 'function' || !document.body) return;
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => this.enqueueNode(node));
});
this.schedulePending();
});
this.observer.observe(document.body, { childList: true, subtree: true });
},
enqueueNode(node) {
if (!(node instanceof Element) || Dom.isOwnSurface(node)) return;
if (node.matches?.(BADGE_ANCHOR_QUERY)) this.pendingAnchors.add(node);
node.querySelectorAll?.(BADGE_ANCHOR_QUERY).forEach((anchor) => {
if (!Dom.isOwnSurface(anchor)) this.pendingAnchors.add(anchor);
});
},
schedulePending(delay = Math.max(80, Prefs.value.badgeRefreshDelay)) {
if (!this.pendingAnchors.size || this.pendingTimer) return;
this.pendingTimer = window.setTimeout(() => {
this.pendingTimer = 0;
this.flushPending();
}, delay);
},
flushPending() {
if (!this.pendingAnchors.size) return;
const anchors = [];
for (const anchor of this.pendingAnchors) {
this.pendingAnchors.delete(anchor);
anchors.push(anchor);
if (anchors.length >= this.pendingBatchSize) break;
}
this.refreshAnchors(anchors);
if (this.pendingAnchors.size) this.schedulePending(16);
},
refresh() {
this.refreshAnchors(this.topicAnchors(), { cleanup: true });
},
refreshAnchors(anchors, options = {}) {
if (options.cleanup) this.removeMisplacedBadges();
const context = this.refreshContext();
const seenAnchors = new Set();
anchors.forEach((anchor) => {
if (!(anchor instanceof Element) || !anchor.isConnected || Dom.isOwnSurface(anchor) || seenAnchors.has(anchor)) return;
seenAnchors.add(anchor);
this.refreshAnchor(anchor, context);
});
if (options.cleanup && !ReadMemory.items.length) {
this.clearAllDecorations();
}
},
refreshContext() {
return {
memoryById: new Map(ReadMemory.items.map((item) => [item.id, item])),
keywordTerms: KeywordRules.preparedTerms(),
similarCandidates: SimilarTopics.candidates()
};
},
topicAnchors(root = document) {
return Array.from(root.querySelectorAll?.(BADGE_ANCHOR_QUERY) || [])
.filter((anchor) => !Dom.isOwnSurface(anchor));
},
refreshTopic(topicId, sourceAnchor = null) {
const id = String(topicId || '');
if (!id) return;
const anchor = this.badgeAnchorFor(sourceAnchor, id, { localOnly: !!sourceAnchor }) || this.findAnchor(id);
if (anchor) this.refreshAnchor(anchor);
},
clearAllDecorations() {
document.querySelectorAll(`.${NAME}-read-badge, .${NAME}-effective-badge`).forEach((badge) => badge.remove());
},
refreshAnchor(anchor, context = null) {
if (!(anchor instanceof Element) || !anchor.isConnected) return;
const topicId = Urls.topicIdFromHref(anchor.getAttribute('href'));
if (!topicId) return;
const badgeAnchor = this.badgeAnchorFor(anchor, topicId, { localOnly: true });
if (!badgeAnchor) return;
const refreshContext = context || this.refreshContext();
CategoryMarks.refreshAnchor(badgeAnchor);
KeywordRules.refreshAnchor(badgeAnchor, refreshContext.keywordTerms);
TopicDates.refreshAnchor(badgeAnchor);
SimilarTopics.refreshAnchor(badgeAnchor, refreshContext.similarCandidates);
const memory = refreshContext.memoryById?.get(topicId) || null;
if (memory) {
this.decorate(badgeAnchor, topicId, memory);
return;
}
this.removeReadBadge(badgeAnchor);
this.syncEffectiveBadge(badgeAnchor, null, false);
},
rebuildNewTopicIndex() {
this.newTopicIds = new Set();
document.querySelectorAll(this.newTopicSelector()).forEach((badge) => {
if (badge.closest(`#${NAME}-drawer, #${NAME}-eye`)) return;
if (badge.getAttribute('title') !== '新话题') return;
const topicId = Urls.topicIdFromHref(badge.getAttribute('href'));
if (topicId) this.newTopicIds.add(topicId);
});
this.newTopicIndexReady = true;
},
decorate(anchor, topicId, memory) {
if (!anchor.matches(BADGE_ANCHOR_QUERY)) return;
const row = this.topicRow(anchor);
const hasNewTopicBadge = this.hasNewTopicBadge(topicId, row);
if (hasNewTopicBadge && !memory.trackedNewTopic) {
ReadMemory.markTrackedNewTopic(topicId);
memory = ReadMemory.get(topicId) || memory;
}
const trackedNewTopic = memory.trackedNewTopic === true;
const effective = trackedNewTopic && (memory.effective || !hasNewTopicBadge);
if (effective && !memory.effective) {
ReadMemory.markEffective(topicId);
memory = ReadMemory.get(topicId) || memory;
}
const readBadge = Prefs.value.showReadBadges ? this.ensureReadBadge(anchor) : null;
if (readBadge) readBadge.title = '脚本已记录该话题已预览';
else this.removeReadBadge(anchor);
this.syncEffectiveBadge(anchor, readBadge, effective && Prefs.value.showEffectiveBadges);
},
ensureReadBadge(anchor) {
const existing = anchor.nextElementSibling?.classList?.contains(`${NAME}-read-badge`)
? anchor.nextElementSibling
: null;
if (existing) {
existing.classList.remove('is-effective');
if (existing.textContent !== '已读') existing.textContent = '已读';
return existing;
}
const badge = Dom.make('span', { className: `${NAME}-read-badge`, text: '已读' });
const effectiveBadge = anchor.nextElementSibling?.classList?.contains(`${NAME}-effective-badge`)
? anchor.nextElementSibling
: null;
if (effectiveBadge) anchor.parentNode.insertBefore(badge, effectiveBadge);
else anchor.insertAdjacentElement('afterend', badge);
return badge;
},
removeReadBadge(anchor) {
const badge = anchor.nextElementSibling?.classList?.contains(`${NAME}-read-badge`)
? anchor.nextElementSibling
: null;
badge?.remove();
},
badgeAnchorFor(anchor, topicId, options = {}) {
const id = String(topicId || '');
if (!id || !(anchor instanceof Element)) return null;
if (anchor.matches(BADGE_ANCHOR_QUERY) && Urls.topicIdFromHref(anchor.getAttribute('href')) === id) {
return anchor;
}
const row = this.topicRow(anchor);
const rowAnchor = Array.from(row?.querySelectorAll?.(BADGE_ANCHOR_QUERY) || []).find((candidate) => {
return Urls.topicIdFromHref(candidate.getAttribute('href')) === id;
});
if (rowAnchor) return rowAnchor;
if (options.localOnly) return null;
return Array.from(document.querySelectorAll(BADGE_ANCHOR_QUERY)).find((candidate) => {
if (Dom.isOwnSurface(candidate)) return false;
return Urls.topicIdFromHref(candidate.getAttribute('href')) === id;
}) || null;
},
findAnchor(topicId) {
const id = String(topicId || '');
if (!id) return null;
return Array.from(document.querySelectorAll(BADGE_ANCHOR_QUERY)).find((anchor) => {
if (Dom.isOwnSurface(anchor)) return false;
return Urls.topicIdFromHref(anchor.getAttribute('href')) === id;
}) || null;
},
removeMisplacedBadges() {
document.querySelectorAll(`.${NAME}-read-badge`).forEach((badge) => {
const anchor = badge.previousElementSibling;
if (Prefs.value.showReadBadges && anchor?.matches?.(BADGE_ANCHOR_QUERY)) return;
badge.remove();
});
document.querySelectorAll(`.${NAME}-effective-badge`).forEach((badge) => {
const previous = badge.previousElementSibling;
const anchor = previous?.classList?.contains(`${NAME}-read-badge`)
? previous.previousElementSibling
: previous;
if (Prefs.value.showEffectiveBadges && anchor?.matches?.(BADGE_ANCHOR_QUERY)) return;
badge.remove();
});
},
syncEffectiveBadge(anchor, readBadge, effective) {
const target = readBadge || anchor;
const existing = target.nextElementSibling?.classList?.contains(`${NAME}-effective-badge`)
? target.nextElementSibling
: anchor.nextElementSibling?.classList?.contains(`${NAME}-effective-badge`)
? anchor.nextElementSibling
: null;
if (!effective) {
existing?.remove();
return;
}
const badge = existing || Dom.make('span', {
className: `${NAME}-effective-badge`,
text: '话题已生效',
title: '预览时存在 new-topic 小蓝点,现已消失'
});
badge.title = '预览时存在 new-topic 小蓝点,现已消失';
if (!existing) target.insertAdjacentElement('afterend', badge);
else if (existing.previousElementSibling !== target) target.insertAdjacentElement('afterend', existing);
},
topicRow(anchor) {
return anchor.closest([
'tr.topic-list-item',
'.topic-list-item',
'.latest-topic-list-item',
'.search-result-topic',
'.fps-result',
'.suggested-topics-list-item',
'li',
'article'
].join(',')) || anchor.parentElement;
},
hasNewTopicBadge(topicId, row) {
const id = String(topicId || '');
if (!id) return false;
if (row && this.scopeHasNewTopicBadge(row, id)) return true;
return this.newTopicIds.has(id);
},
scopeHasNewTopicBadge(scope, topicId) {
return Array.from(scope.querySelectorAll?.(this.newTopicSelector()) || []).some((badge) => {
if (badge.closest(`#${NAME}-drawer, #${NAME}-eye`)) return false;
return badge.getAttribute('title') === '新话题' &&
Urls.topicIdFromHref(badge.getAttribute('href')) === String(topicId);
});
},
newTopicSelector() {
return 'a.badge.badge-notification.new-topic[title="新话题"][href*="/t/"]';
},
watchEffect(topicId) {
const id = String(topicId || '');
if (!id || this.watchingTopics.has(id)) return;
this.watchingTopics.add(id);
let left = 12;
const tick = () => {
this.refreshTopic(id);
if (ReadMemory.get(id)?.effective) {
Drawer.syncTopicStatus();
this.watchingTopics.delete(id);
return;
}
left -= 1;
if (left > 0) {
window.setTimeout(tick, 1000);
} else {
this.watchingTopics.delete(id);
}
};
window.setTimeout(tick, 1000);
}
};
// 将列表右侧活动列里的创建/更新时间提前显示到话题标题旁边。
const TopicDates = {
dateFormatter: null,
refresh() {
this.clearOrphans();
document.querySelectorAll(BADGE_ANCHOR_QUERY).forEach((anchor) => {
if (Dom.isOwnSurface(anchor)) return;
this.decorate(anchor);
});
},
refreshAnchor(anchor) {
if (!anchor?.matches?.(BADGE_ANCHOR_QUERY) || Dom.isOwnSurface(anchor)) return;
this.decorate(anchor);
},
clearOrphans() {
document.querySelectorAll(`.${NAME}-date-badge`).forEach((badge) => {
if (this.anchorForBadge(badge)) return;
badge.remove();
});
},
clearAnchor(anchor) {
this.badgeFor(anchor)?.remove();
},
decorate(anchor) {
const info = this.info(anchor);
if (!info) {
this.clearAnchor(anchor);
return;
}
const badge = this.ensureBadge(anchor);
if (!badge) return;
badge.textContent = info.text;
badge.title = info.title;
},
info(anchor) {
const row = TopicBadges.topicRow(anchor);
if (!row) return null;
const topicId = Urls.topicIdFromHref(anchor.getAttribute('href'));
const activity = this.activityCell(row, topicId);
if (!activity) return null;
const created = this.createdInfo(activity);
const updated = this.updatedInfo(activity);
const textParts = [];
const titleParts = [];
if (created?.short) textParts.push(`创 ${created.short}`);
if (updated?.short) textParts.push(`更 ${updated.short}`);
if (created?.full) titleParts.push(`创建日期:${created.full}`);
if (updated?.full) titleParts.push(`更新日期:${updated.full}`);
if (!textParts.length) return null;
return {
text: textParts.join(' · '),
title: titleParts.join(';') || textParts.join(' · ')
};
},
activityCell(row, topicId) {
const cells = Array.from(row.querySelectorAll([
'td.activity.topic-list-data.age',
'td.topic-list-data.age',
'.activity.topic-list-data.age',
'.topic-list-data.age'
].join(',')));
if (!cells.length) return null;
const id = String(topicId || '');
if (!id) return cells[0];
return cells.find((cell) => {
const href = cell.querySelector('a.post-activity[href], a[href*="/t/"]')?.getAttribute('href');
return Urls.topicIdFromHref(href) === id;
}) || cells[0];
},
createdInfo(activity) {
const title = String(activity.getAttribute('title') || '').replace(/\s+/g, ' ').trim();
if (!title) return null;
const raw = this.extractTitleValue(title, '创建日期') || title;
return this.compactDateText(raw);
},
updatedInfo(activity) {
const dateNode = activity.querySelector('.relative-date[data-time], [data-time], time[datetime]');
const timestamp = dateNode?.getAttribute?.('data-time');
const fromTimestamp = this.formatTimestamp(timestamp);
if (fromTimestamp) return fromTimestamp;
const dateTime = dateNode?.getAttribute?.('datetime');
const fromDateTime = this.formatTimestamp(Date.parse(dateTime));
if (fromDateTime) return fromDateTime;
const title = String(dateNode?.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim();
const fromTitle = this.compactDateText(title);
if (fromTitle) return fromTitle;
const text = String(dateNode?.textContent || '').replace(/\s+/g, ' ').trim();
return text ? { short: text, full: text } : null;
},
extractTitleValue(title, label) {
const match = String(title || '').match(new RegExp(`${label}\\s*[::]\\s*([^;;]+)`));
return match?.[1]?.trim() || '';
},
compactDateText(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (!text) return null;
const normalized = text.replace(/^创建日期\s*[::]\s*/, '').replace(/^更新日期\s*[::]\s*/, '').trim();
const chinese = normalized.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*(\d{1,2})\s*[::]\s*(\d{2})/);
if (chinese) {
const [, year, month, day, hour, minute] = chinese;
return {
short: `${this.pad(month)}-${this.pad(day)} ${this.pad(hour)}:${minute}`,
full: `${year}-${this.pad(month)}-${this.pad(day)} ${this.pad(hour)}:${minute}`
};
}
const numeric = normalized.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})\s+(\d{1,2})\s*[::]\s*(\d{2})/);
if (numeric) {
const [, year, month, day, hour, minute] = numeric;
return {
short: `${this.pad(month)}-${this.pad(day)} ${this.pad(hour)}:${minute}`,
full: `${year}-${this.pad(month)}-${this.pad(day)} ${this.pad(hour)}:${minute}`
};
}
return { short: normalized, full: normalized };
},
formatTimestamp(value) {
const raw = Number(value);
if (!Number.isFinite(raw) || raw <= 0) return null;
const date = new Date(raw < 1e12 ? raw * 1000 : raw);
if (Number.isNaN(date.getTime())) return null;
const parts = {};
this.datePartsFormatter().formatToParts(date).forEach((part) => {
if (part.type !== 'literal') parts[part.type] = part.value;
});
if (!parts.year || !parts.month || !parts.day || !parts.hour || !parts.minute) return null;
return {
short: `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
full: `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
};
},
datePartsFormatter() {
if (!this.dateFormatter) {
this.dateFormatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
return this.dateFormatter;
},
ensureBadge(anchor) {
let target = anchor;
let current = anchor.nextElementSibling;
let existing = null;
while (current && this.isTopicBadge(current)) {
if (current.classList.contains(`${NAME}-date-badge`)) {
existing = current;
break;
}
target = current;
current = current.nextElementSibling;
}
const badge = existing || Dom.make('span', { className: `${NAME}-date-badge` });
if (!existing || existing.previousElementSibling !== target) {
target.insertAdjacentElement('afterend', badge);
}
return badge;
},
badgeFor(anchor) {
let current = anchor?.nextElementSibling;
while (current && this.isTopicBadge(current)) {
if (current.classList.contains(`${NAME}-date-badge`)) return current;
current = current.nextElementSibling;
}
return null;
},
anchorForBadge(badge) {
let current = badge?.previousElementSibling;
while (current && this.isTopicBadge(current)) {
current = current.previousElementSibling;
}
return current?.matches?.(BADGE_ANCHOR_QUERY) ? current : null;
},
isTopicBadge(element) {
return !!element?.classList && [
`${NAME}-read-badge`,
`${NAME}-effective-badge`,
`${NAME}-last-viewed-badge`,
`${NAME}-keyword-badge`,
`${NAME}-date-badge`,
`${NAME}-similar-badge`
].some((className) => element.classList.contains(className));
},
pad(value) {
return String(value).padStart(2, '0');
}
};
// 按标题相似度提示最近看过的近似话题,全部基于本地阅读记忆。
const SimilarTopics = {
threshold: 0.58,
maxCandidates: 500,
maxMatches: 2,
candidateCache: null,
candidateCacheKey: '',
candidateIndex: null,
invalidate() {
this.candidateCache = null;
this.candidateCacheKey = '';
this.candidateIndex = null;
},
refresh() {
const candidates = this.candidates();
if (!candidates.length) {
this.clear();
return;
}
this.clearOrphans();
document.querySelectorAll(BADGE_ANCHOR_QUERY).forEach((anchor) => {
if (Dom.isOwnSurface(anchor)) return;
this.decorate(anchor, candidates);
});
},
refreshAnchor(anchor, preparedCandidates = null) {
if (!anchor?.matches?.(BADGE_ANCHOR_QUERY) || Dom.isOwnSurface(anchor)) return;
const candidates = preparedCandidates || this.candidates();
if (!candidates.length) {
this.clearAnchor(anchor);
return;
}
this.decorate(anchor, candidates);
},
candidates() {
const cacheKey = this.cacheKey();
if (this.candidateCache && this.candidateCacheKey === cacheKey) return this.candidateCache;
const candidates = ReadMemory.items
.slice(0, Math.min(this.maxCandidates, Prefs.value.memoryLimit))
.map((item) => {
const normalized = this.normalizeTitle(item.title);
const tokens = this.tokens(normalized);
if (tokens.size < 2) return null;
return { item, normalized, tokens };
})
.filter(Boolean);
this.candidateCache = candidates;
this.candidateCacheKey = cacheKey;
this.candidateIndex = this.indexCandidates(candidates);
return candidates;
},
cacheKey() {
return [
ReadMemory.revision,
Math.min(this.maxCandidates, Prefs.value.memoryLimit),
this.maxCandidates
].join(':');
},
indexCandidates(candidates) {
const index = new Map();
candidates.forEach((candidate) => {
candidate.tokens.forEach((token) => {
const bucket = index.get(token) || [];
bucket.push(candidate);
index.set(token, bucket);
});
});
return index;
},
candidatePool(current, candidates) {
if (!this.candidateIndex || !current?.tokens?.size) return candidates;
const seen = new Set();
const pool = [];
current.tokens.forEach((token) => {
const bucket = this.candidateIndex.get(token);
if (!bucket) return;
bucket.forEach((candidate) => {
if (seen.has(candidate.item.id)) return;
seen.add(candidate.item.id);
pool.push(candidate);
});
});
return pool;
},
decorate(anchor, candidates) {
const current = this.currentTopic(anchor);
if (!current?.normalized || current.tokens.size < 2 || !candidates.length) {
this.clearAnchor(anchor);
return;
}
const matches = this.candidatePool(current, candidates)
.filter((candidate) => candidate.item.id !== current.id)
.map((candidate) => ({
...candidate,
score: this.score(current, candidate)
}))
.filter((candidate) => candidate.score >= this.threshold)
.sort((a, b) => b.score - a.score || b.item.readAt - a.item.readAt)
.slice(0, this.maxMatches);
if (!matches.length) {
this.clearAnchor(anchor);
return;
}
const badge = this.ensureBadge(anchor);
if (!badge) return;
const best = matches[0];
badge.textContent = matches.length > 1 ? `相似 ${matches.length}` : '相似';
badge.title = [
`可能重复:${Math.round(best.score * 100)}%`,
...matches.map((match, index) => `${index + 1}. ${match.item.title}`)
].join('\n');
badge.dataset.similarTopicId = best.item.id;
badge.dataset.similarScore = String(Math.round(best.score * 100));
},
currentTopic(anchor) {
const title = Favorites.cleanTitle(Favorites.titleFromAnchor(anchor, Urls.topicIdFromHref(anchor.getAttribute('href'))));
const normalized = this.normalizeTitle(title);
return {
id: Urls.topicIdFromHref(anchor.getAttribute('href')),
normalized,
tokens: this.tokens(normalized)
};
},
score(current, candidate) {
if (!current.normalized || !candidate.normalized) return 0;
if (current.normalized === candidate.normalized) return 1;
const dice = this.dice(current.tokens, candidate.tokens);
const shorter = current.normalized.length <= candidate.normalized.length ? current.normalized : candidate.normalized;
const longer = current.normalized.length > candidate.normalized.length ? current.normalized : candidate.normalized;
const containsBoost = shorter.length >= 8 && longer.includes(shorter) ? 0.86 : 0;
return Math.max(dice, containsBoost);
},
dice(a, b) {
if (!a.size || !b.size) return 0;
let intersection = 0;
a.forEach((token) => {
if (b.has(token)) intersection += 1;
});
return (2 * intersection) / (a.size + b.size);
},
normalizeTitle(title) {
return String(title || '')
.toLowerCase()
.normalize('NFKC')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[^\p{Script=Han}\p{Letter}\p{Number}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
},
tokens(normalized) {
const tokens = new Set();
const words = String(normalized || '').match(/[\p{Letter}\p{Number}]+/gu) || [];
words.forEach((word) => {
if (/^[\p{Script=Han}]+$/u.test(word)) {
if (word.length === 1) {
tokens.add(word);
return;
}
for (let index = 0; index < word.length - 1; index += 1) {
tokens.add(word.slice(index, index + 2));
}
return;
}
if (word.length >= 2) tokens.add(word);
});
return tokens;
},
ensureBadge(anchor) {
let target = anchor;
let current = anchor.nextElementSibling;
let existing = null;
while (current && this.isTopicBadge(current)) {
if (current.classList.contains(`${NAME}-similar-badge`)) {
existing = current;
break;
}
target = current;
current = current.nextElementSibling;
}
const badge = existing || Dom.make('span', { className: `${NAME}-similar-badge` });
if (!existing || existing.previousElementSibling !== target) {
target.insertAdjacentElement('afterend', badge);
}
return badge;
},
clear() {
document.querySelectorAll(`.${NAME}-similar-badge`).forEach((badge) => badge.remove());
},
clearOrphans() {
document.querySelectorAll(`.${NAME}-similar-badge`).forEach((badge) => {
if (this.anchorForBadge(badge)) return;
badge.remove();
});
},
clearAnchor(anchor) {
this.badgeFor(anchor)?.remove();
},
badgeFor(anchor) {
let current = anchor?.nextElementSibling;
while (current && this.isTopicBadge(current)) {
if (current.classList.contains(`${NAME}-similar-badge`)) return current;
current = current.nextElementSibling;
}
return null;
},
anchorForBadge(badge) {
let current = badge?.previousElementSibling;
while (current && this.isTopicBadge(current)) {
current = current.previousElementSibling;
}
return current?.matches?.(BADGE_ANCHOR_QUERY) ? current : null;
},
isTopicBadge(element) {
return !!element?.classList && [
`${NAME}-read-badge`,
`${NAME}-effective-badge`,
`${NAME}-last-viewed-badge`,
`${NAME}-keyword-badge`,
`${NAME}-date-badge`,
`${NAME}-similar-badge`
].some((className) => element.classList.contains(className));
}
};
// 给不同分类补充稳定的本地颜色提示;优先复用 Discourse 自带分类颜色。
const CategoryMarks = {
palette: ['#4f7bd9', '#2f9e89', '#c47a24', '#8b6ed8', '#d85c7a', '#4a9fcf', '#7d9a2e', '#c65f3d'],
refresh() {
this.clear();
if (!Prefs.value.showCategoryColors) return;
document.querySelectorAll(BADGE_ANCHOR_QUERY).forEach((anchor) => {
if (Dom.isOwnSurface(anchor)) return;
this.decorate(anchor);
});
},
refreshAnchor(anchor) {
if (!anchor?.matches?.(BADGE_ANCHOR_QUERY) || Dom.isOwnSurface(anchor)) return;
this.clearAnchor(anchor);
if (Prefs.value.showCategoryColors) this.decorate(anchor);
},
clear() {
document.querySelectorAll(`.${NAME}-category-marked`).forEach((element) => {
element.classList.remove(`${NAME}-category-marked`);
element.style.removeProperty('--ldpeek-category-color');
element.removeAttribute('data-category-name');
});
},
clearAnchor(anchor) {
const row = TopicBadges.topicRow(anchor) || anchor;
row.classList.remove(`${NAME}-category-marked`);
row.style.removeProperty('--ldpeek-category-color');
row.removeAttribute('data-category-name');
},
decorate(anchor) {
const info = this.categoryInfo(anchor);
if (!info?.name) return;
const row = TopicBadges.topicRow(anchor) || anchor;
const color = info.color || this.colorFor(info.name);
row.classList.add(`${NAME}-category-marked`);
row.style.setProperty('--ldpeek-category-color', color);
row.dataset.categoryName = info.name;
},
categoryInfo(anchor) {
const row = TopicBadges.topicRow(anchor);
if (!row) return null;
const node = row.querySelector([
'.badge-category__wrapper',
'.badge-category',
'.topic-category',
'a[href^="/c/"]',
'a[href*="/c/"]'
].join(','));
if (!node || node.contains(anchor)) return null;
const nameNode = node.querySelector?.('.badge-category__name, .category-name') || node;
const name = String(nameNode?.textContent || node.getAttribute?.('title') || node.getAttribute?.('aria-label') || '')
.replace(/\s+/g, ' ')
.trim();
if (!name) return null;
return { name, color: this.colorFromNode(node) };
},
colorFromNode(node) {
const style = window.getComputedStyle?.(node);
const candidates = [
node.style?.getPropertyValue?.('--category-badge-color'),
node.style?.getPropertyValue?.('--category-color'),
style?.getPropertyValue?.('--category-badge-color'),
style?.getPropertyValue?.('--category-color'),
style?.borderLeftColor,
style?.backgroundColor,
style?.color
].map((value) => String(value || '').trim()).filter(Boolean);
return candidates.find((value) => {
return value !== 'transparent' && value !== 'rgba(0, 0, 0, 0)' && value !== 'inherit';
}) || '';
},
colorFor(name) {
let hash = 0;
for (const char of String(name)) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
return this.palette[hash % this.palette.length];
}
};
// 本地关键词规则只作用于真实话题标题,不处理抽屉和脚本自身 UI。
const KeywordRules = {
hasDecorations: false,
terms(value) {
return Prefs.cleanTextSetting(value)
.split(/[\n,,;;]+/)
.flatMap((part) => part.trim().split(/\s+/))
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
},
title(anchor) {
return String(anchor?.textContent || anchor?.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim();
},
match(title, terms) {
const normalized = title.toLowerCase();
return terms.find((term) => normalized.includes(term)) || '';
},
preparedTerms() {
return {
blockTerms: this.terms(Prefs.value.keywordBlockList),
highlightTerms: this.terms(Prefs.value.keywordHighlightList)
};
},
refresh() {
const { blockTerms, highlightTerms } = this.preparedTerms();
if (!blockTerms.length && !highlightTerms.length) {
if (this.hasDecorations) this.clear();
return;
}
this.clear();
document.querySelectorAll(BADGE_ANCHOR_QUERY).forEach((anchor) => {
if (Dom.isOwnSurface(anchor)) return;
this.decorate(anchor, blockTerms, highlightTerms);
});
},
refreshAnchor(anchor, prepared = null) {
if (!anchor?.matches?.(BADGE_ANCHOR_QUERY) || Dom.isOwnSurface(anchor)) return;
this.clearAnchor(anchor);
const { blockTerms, highlightTerms } = prepared || this.preparedTerms();
if (!blockTerms.length && !highlightTerms.length) return;
this.decorate(anchor, blockTerms, highlightTerms);
},
clear() {
document.querySelectorAll(`.${NAME}-keyword-badge`).forEach((badge) => badge.remove());
document.querySelectorAll(`.${NAME}-keyword-highlight`).forEach((element) => {
element.classList.remove(`${NAME}-keyword-highlight`);
element.removeAttribute('data-keyword-match');
});
document.querySelectorAll(`.${NAME}-keyword-blocked`).forEach((element) => {
element.classList.remove(`${NAME}-keyword-blocked`);
element.removeAttribute('data-keyword-match');
});
this.hasDecorations = false;
},
clearAnchor(anchor) {
const row = TopicBadges.topicRow(anchor) || anchor;
row.classList.remove(`${NAME}-keyword-highlight`, `${NAME}-keyword-blocked`);
row.removeAttribute('data-keyword-match');
anchor.classList.remove(`${NAME}-keyword-highlight`);
anchor.removeAttribute('data-keyword-match');
let next = anchor.nextElementSibling;
while (next) {
const current = next;
next = next.nextElementSibling;
if (current.classList?.contains(`${NAME}-keyword-badge`)) current.remove();
}
},
decorate(anchor, blockTerms, highlightTerms) {
const title = this.title(anchor);
if (!title) return;
const row = TopicBadges.topicRow(anchor) || anchor;
const blocked = this.match(title, blockTerms);
if (blocked) {
row.classList.add(`${NAME}-keyword-blocked`);
row.dataset.keywordMatch = blocked;
this.hasDecorations = true;
return;
}
const highlighted = this.match(title, highlightTerms);
if (!highlighted) return;
anchor.classList.add(`${NAME}-keyword-highlight`);
anchor.dataset.keywordMatch = highlighted;
this.hasDecorations = true;
this.ensureBadge(anchor, highlighted);
},
ensureBadge(anchor, keyword) {
let target = anchor;
while (target.nextElementSibling?.classList?.contains(`${NAME}-read-badge`) ||
target.nextElementSibling?.classList?.contains(`${NAME}-effective-badge`)) {
target = target.nextElementSibling;
}
const existing = target.nextElementSibling?.classList?.contains(`${NAME}-keyword-badge`)
? target.nextElementSibling
: null;
const badge = existing || Dom.make('span', {
className: `${NAME}-keyword-badge`,
text: '关键词'
});
badge.title = `命中高亮关键词:${keyword}`;
if (!existing) target.insertAdjacentElement('afterend', badge);
}
};
// 小容量话题缓存;预览模式使用 `/t/{id}.json` 里的已加载帖子生成摘要视图。
const StarterPostStore = {
cache: new Map(),
cacheBytes: new Map(),
preloadAt: new Map(),
async load(topicId) {
if (this.cache.has(topicId)) {
const cached = this.cache.get(topicId);
this.cache.delete(topicId);
this.cache.set(topicId, cached);
return cached;
}
const pending = fetch(Urls.topicJson(topicId), {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
})
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((payload) => {
const model = this.toCardModel(topicId, payload);
if (this.cache.get(topicId) === pending) {
this.cacheBytes.set(topicId, estimateSerializedBytes(model));
}
return model;
});
this.cache.set(topicId, pending);
this.trimCache();
pending.catch(() => {
if (this.cache.get(topicId) === pending) {
this.cache.delete(topicId);
this.cacheBytes.delete(topicId);
}
});
return pending;
},
preload(topicId) {
const id = String(topicId || '');
if (!id || Prefs.value.mode !== 'summary') return;
const last = this.preloadAt.get(id) || 0;
if (Date.now() - last < Prefs.value.previewCacheTtl * 1000) return;
this.preloadAt.set(id, Date.now());
this.load(id).catch(() => {
// 预加载失败不打扰用户,正式打开时仍会显示错误兜底。
});
this.trimPreloadMarks();
},
trimCache() {
while (this.cache.size > Prefs.value.topicCacheLimit) {
const key = this.cache.keys().next().value;
this.cache.delete(key);
this.cacheBytes.delete(key);
}
},
trimPreloadMarks() {
while (this.preloadAt.size > Prefs.value.topicCacheLimit * 3) {
this.preloadAt.delete(this.preloadAt.keys().next().value);
}
},
clearVolatileCache() {
const count = this.cache.size + this.preloadAt.size;
this.cache.clear();
this.cacheBytes.clear();
this.preloadAt.clear();
return count;
},
toCardModel(topicId, payload) {
const posts = Array.isArray(payload?.post_stream?.posts)
? payload.post_stream.posts.filter((post) => post?.cooked)
: [];
const first = posts[0];
if (!first?.cooked) throw new Error('话题正文为空');
const replies = posts
.filter((post) => post !== first && Number(post.post_number || 0) > Number(first.post_number || 1))
.sort((a, b) => Number(a.post_number || 0) - Number(b.post_number || 0));
const topReply = replies
.filter((post) => this.postLikes(post) > 0)
.sort((a, b) => this.postLikes(b) - this.postLikes(a) || Number(b.post_number || 0) - Number(a.post_number || 0))[0] || null;
const latestReply = replies[replies.length - 1] || null;
const starter = this.postSummary('starter', '楼主', first, {
fallbackEmpty: '暂无楼主正文'
});
const top = topReply
? this.postSummary('top', '高赞回复', topReply, {
badge: `赞 ${this.postLikes(topReply)}`,
fallbackEmpty: '暂无高赞回复正文'
})
: this.emptySummary('top', '高赞回复', replies.length ? '当前已加载回复暂无点赞' : '暂无回复');
const latest = latestReply
? this.postSummary('latest', '最新回复', latestReply, {
badge: `#${Number(latestReply.post_number || 0) || replies.length + 1}`,
fallbackEmpty: '暂无最新回复正文'
})
: this.emptySummary('latest', '最新回复', '暂无回复');
return {
id: topicId,
title: payload.title || payload.fancy_title || `话题 ${topicId}`,
displayName: starter.displayName,
username: starter.username,
avatar: starter.avatar,
createdAt: starter.createdAt,
likes: starter.likes,
cooked: starter.cooked,
replyCount: Math.max(0, Number(payload.posts_count || posts.length) - 1),
loadedReplyCount: replies.length,
summaries: [starter, top, latest],
stats: {
postsCount: Number(payload.posts_count || 0),
highestPostNumber: Number(payload.highest_post_number || 0),
streamCount: Array.isArray(payload.post_stream?.stream) ? payload.post_stream.stream.length : 0,
participantCount: Number(payload.participant_count || 0),
views: Number(payload.views || 0),
likeCount: Number(payload.like_count || 0),
wordCount: Number(payload.word_count || 0),
closed: !!payload.closed,
archived: !!payload.archived,
createdAt: payload.created_at || '',
lastPostedAt: payload.last_posted_at || '',
starterDisplayName: starter.displayName,
starterUsername: starter.username,
starterLikeCount: starter.likes,
starterReads: Number(first.reads || first.readers_count || 0),
starterReplyCount: Number(first.reply_count || 0),
starterCreatedAt: starter.createdAt
}
};
},
postSummary(key, label, post, options = {}) {
const postNumber = Number(post?.post_number || 0);
return {
key,
label,
badge: options.badge || (postNumber ? `#${postNumber}` : ''),
displayName: post?.name || post?.display_username || post?.username || '未知用户',
username: post?.username || post?.display_username || '',
avatar: this.avatarUrl(post?.avatar_template, 96),
createdAt: post?.created_at || '',
likes: this.postLikes(post),
postNumber,
cooked: this.cleanCooked(post?.cooked || options.fallbackEmpty || ''),
empty: false
};
},
emptySummary(key, label, text) {
return {
key,
label,
badge: '',
displayName: '',
username: '',
avatar: '',
createdAt: '',
likes: 0,
postNumber: 0,
cooked: '',
empty: true,
emptyText: text || '暂无内容'
};
},
postLikes(post) {
const direct = Number(post?.like_count);
if (Number.isFinite(direct) && direct > 0) return direct;
const like = Array.isArray(post?.actions_summary)
? post.actions_summary.find((item) => item?.id === 2 || item?.name_key === 'like')
: null;
const count = Number(like?.count);
return Number.isFinite(count) && count > 0 ? count : 0;
},
avatarUrl(template, size) {
if (!template) return '';
return Urls.absolute(template.replace('{size}', String(size)));
},
cleanCooked(html) {
const box = document.createElement('div');
box.innerHTML = html;
box.querySelectorAll('script, style, object, embed, form').forEach((node) => node.remove());
box.querySelectorAll('[onload], [onclick], [onerror], [onmouseover]').forEach((node) => {
node.removeAttribute('onload');
node.removeAttribute('onclick');
node.removeAttribute('onerror');
node.removeAttribute('onmouseover');
});
box.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || img.getAttribute('data-src');
if (src) img.setAttribute('src', Urls.absolute(src));
img.removeAttribute('width');
img.removeAttribute('height');
img.setAttribute('loading', 'lazy');
});
box.querySelectorAll('a[href]').forEach((link) => {
link.setAttribute('href', Urls.absolute(link.getAttribute('href')));
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
});
return box.innerHTML.trim();
}
};
// 话题链接悬浮或聚焦后显示的小型预览入口。
const MiniEye = {
button: null,
statsPanel: null,
anchor: null,
timer: 0,
preloadTimer: 0,
statsTicket: 0,
mount() {
if (this.button) return this.button;
this.button = Dom.make('div', {
id: `${NAME}-eye`,
role: 'group',
'aria-label': '话题快捷操作'
}, [
Dom.make('button', {
className: `${NAME}-eye-btn ${NAME}-eye-preview`,
type: 'button',
title: '打开抽屉预览',
'aria-label': '打开抽屉预览',
'data-eye-action': 'preview'
}, Dom.make('span', { className: `${NAME}-book-preview`, 'aria-hidden': 'true' }, [
Dom.make('span', { className: `${NAME}-book-pages` }),
Dom.make('span', { className: `${NAME}-book-cover` })
])),
Dom.make('button', {
className: `${NAME}-eye-btn ${NAME}-eye-later`,
type: 'button',
title: '加入稍后阅读',
'aria-label': '加入稍后阅读',
'data-eye-action': 'read-later',
text: '+'
}),
Dom.make('button', {
className: `${NAME}-eye-btn ${NAME}-eye-stats`,
type: 'button',
title: '话题统计',
'aria-label': '话题统计',
'data-eye-action': 'stats'
}, Dom.make('span', { className: `${NAME}-eye-stats-icon`, 'aria-hidden': 'true' }))
]);
this.statsPanel = Dom.make('section', {
id: `${NAME}-mini-stats`,
'aria-label': '话题统计'
});
this.statsPanel.addEventListener('pointerenter', () => this.cancelHide());
this.statsPanel.addEventListener('pointerleave', () => this.hideSoon());
this.statsPanel.addEventListener('click', (event) => {
const copyTarget = event.target.closest('[data-mini-stats-copy]');
if (copyTarget) {
event.preventDefault();
copyText(copyTarget.getAttribute('data-mini-stats-copy'), copyTarget.getAttribute('data-mini-stats-copy-message') || '已复制');
return;
}
const action = event.target.closest('[data-mini-stats-action]')?.dataset.miniStatsAction;
if (!action) return;
event.preventDefault();
if (action === 'close') {
this.hideStats();
return;
}
if (action === 'drawer') {
const topicId = this.statsPanel.dataset.topicId || this.button?.dataset.topicId || '';
if (!topicId) return;
const sourceAnchor = this.anchor?.isConnected ? this.anchor : null;
const sourceHref = sourceAnchor?.getAttribute('href') || '';
this.hide();
Drawer.open(topicId, Prefs.value.mode, sourceAnchor, sourceHref, {
sidebarPanel: 'topicStats',
trackSource: 'topicLinks'
});
}
});
this.button.addEventListener('pointerenter', () => this.cancelHide());
this.button.addEventListener('pointerleave', () => this.hideSoon());
this.button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const action = event.target.closest('[data-eye-action]')?.dataset.eyeAction;
const topicId = this.button.dataset.topicId;
if (!topicId || !action) return;
const sourceAnchor = this.anchor?.isConnected ? this.anchor : null;
const sourceHref = sourceAnchor?.getAttribute('href') || '';
if (action === 'read-later') {
const result = ReadLaterQueue.add(topicId, sourceAnchor, sourceHref);
this.syncLaterButton(topicId);
FloatingPrefs.sync();
Drawer.activeSidebarPanel = 'readLater';
Drawer.renderSidebar();
if (result.full) showToast(`稍后阅读最多保留 ${MAX_READ_LATER} 个话题`);
else showToast(result.existing ? '已在稍后阅读队列' : '已加入稍后阅读');
return;
}
if (action === 'stats') {
this.toggleStats(topicId);
return;
}
this.hide();
Drawer.open(topicId, Prefs.value.mode, sourceAnchor, sourceHref, { trackSource: 'topicLinks' });
});
document.body.appendChild(this.button);
document.body.appendChild(this.statsPanel);
this.applyTheme();
return this.button;
},
applyTheme() {
if (!this.statsPanel) return;
const dark = pagePrefersDarkTheme();
this.statsPanel.classList.toggle('is-light', !dark);
},
show(topicId, anchor) {
if (!topicId || !anchor?.isConnected) return;
this.mount();
this.cancelHide();
this.applyTheme();
if (this.button.dataset.topicId && this.button.dataset.topicId !== String(topicId)) {
this.hideStats();
}
this.anchor = anchor;
this.button.dataset.topicId = topicId;
this.syncLaterButton(topicId);
this.place();
this.button.classList.add('is-live');
this.schedulePreload(topicId);
},
syncLaterButton(topicId) {
const laterButton = this.button?.querySelector('[data-eye-action="read-later"]');
if (!laterButton) return;
const active = ReadLaterQueue.has(topicId);
laterButton.classList.toggle('is-active', active);
laterButton.textContent = active ? '✓' : '+';
laterButton.setAttribute('title', active ? '已在稍后阅读' : '加入稍后阅读');
laterButton.setAttribute('aria-label', active ? '已在稍后阅读' : '加入稍后阅读');
},
place() {
if (!this.button || !this.anchor?.isConnected) return;
const rect = this.anchor.getBoundingClientRect();
const buttonWidth = this.button.offsetWidth || 82;
const buttonHeight = this.button.offsetHeight || 36;
const gap = 8;
const margin = 8;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
let left = rect.right + gap;
let top = rect.top + rect.height / 2 - buttonHeight / 2;
if (left + buttonWidth + margin > vw) left = rect.left - buttonWidth - gap;
if (left < margin) {
left = Math.min(Math.max(rect.left, margin), vw - buttonWidth - margin);
top = rect.bottom + gap;
}
left = Math.max(margin, Math.min(left, vw - buttonWidth - margin));
top = Math.max(margin, Math.min(top, vh - buttonHeight - margin));
this.button.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
this.placeStats(left, top, buttonWidth, buttonHeight);
},
placeStats(eyeLeft, eyeTop, eyeWidth, eyeHeight) {
if (!this.statsPanel?.classList.contains('is-open')) return;
const gap = 8;
const margin = 8;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const panelWidth = this.statsPanel.offsetWidth || 306;
const panelHeight = this.statsPanel.offsetHeight || 260;
let left = eyeLeft + eyeWidth + gap;
if (left + panelWidth + margin > vw) left = eyeLeft - panelWidth - gap;
if (left < margin) left = Math.min(Math.max(margin, eyeLeft), vw - panelWidth - margin);
let top = eyeTop + eyeHeight / 2 - panelHeight / 2;
top = Math.max(margin, Math.min(top, vh - panelHeight - margin));
this.statsPanel.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
},
toggleStats(topicId) {
const id = String(topicId || '');
if (!id) return;
if (this.statsPanel?.classList.contains('is-open') && this.statsPanel.dataset.topicId === id) {
this.hideStats();
return;
}
this.showStats(id);
},
async showStats(topicId) {
const id = String(topicId || '');
if (!id) return;
this.mount();
const ticket = ++this.statsTicket;
this.statsPanel.dataset.topicId = id;
this.statsPanel.replaceChildren(this.statsShell('正在加载统计数据...'));
this.statsPanel.classList.add('is-open');
this.place();
try {
const topic = await StarterPostStore.load(id);
if (ticket !== this.statsTicket || this.statsPanel.dataset.topicId !== id) return;
this.statsPanel.replaceChildren(this.statsContent(topic));
this.place();
} catch (_) {
if (ticket !== this.statsTicket || this.statsPanel.dataset.topicId !== id) return;
this.statsPanel.replaceChildren(this.statsShell('统计数据加载失败'));
this.place();
}
},
hideStats() {
this.statsTicket += 1;
if (!this.statsPanel) return;
this.statsPanel.classList.remove('is-open');
this.statsPanel.removeAttribute('data-topic-id');
this.statsPanel.style.transform = 'translate(-999px, -999px)';
},
statsShell(message) {
return Dom.make('div', { className: `${NAME}-mini-stats-card` }, [
this.statsHeader(),
Dom.make('div', { className: `${NAME}-mini-stats-empty`, text: message })
]);
},
statsHeader(topic = null) {
const title = topic?.title || '话题统计';
return Dom.make('div', { className: `${NAME}-mini-stats-head` }, [
Dom.make('div', { className: `${NAME}-mini-stats-title`, title, text: title }),
Dom.make('button', {
className: `${NAME}-mini-stats-close`,
type: 'button',
title: '关闭',
'aria-label': '关闭话题统计',
'data-mini-stats-action': 'close',
text: '×'
})
]);
},
statsContent(topic) {
const s = topic?.stats || {};
const number = (value) => {
const next = Number(value);
return Number.isFinite(next) ? Math.max(0, next) : 0;
};
const formatNumber = (value) => number(value).toLocaleString('zh-CN');
const percent = (value) => Number.isFinite(value) ? `${value.toFixed(1)}%` : '-';
const postsCount = number(s.postsCount);
const highestPostNumber = number(s.highestPostNumber);
const effectiveReplies = Math.max(0, postsCount - 1);
const deletedFloors = Math.max(0, highestPostNumber - postsCount);
const floorValidRate = highestPostNumber > 0 ? postsCount / highestPostNumber * 100 : NaN;
const participantDensity = number(s.views) > 0 ? number(s.participantCount) / number(s.views) * 100 : NaN;
const status = s.closed ? '已关闭' : s.archived ? '已归档' : '开放中';
const row = (label, value, options = {}) => Dom.make(options.copyValue ? 'button' : 'div', {
className: `${NAME}-mini-stats-row${options.warn ? ` ${NAME}-mini-stats-row-warn` : ''}${options.copyValue ? ` ${NAME}-mini-stats-copyable` : ''}`,
type: options.copyValue ? 'button' : undefined,
title: options.copyValue ? options.copyTitle || '点击复制' : String(value),
'data-mini-stats-copy': options.copyValue,
'data-mini-stats-copy-message': options.copyMessage
}, [
Dom.make('span', { className: `${NAME}-mini-stats-label`, text: label }),
Dom.make('span', { className: `${NAME}-mini-stats-value`, text: String(value) })
]);
const meter = (label, value, pct, options = {}) => {
const width = Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) : 0;
return Dom.make('div', { className: `${NAME}-mini-stats-meter${options.warn ? ` ${NAME}-mini-stats-meter-warn` : ''}` }, [
Dom.make('div', { className: `${NAME}-mini-stats-meter-head` }, [
Dom.make('span', { className: `${NAME}-mini-stats-label`, text: label }),
Dom.make('span', { className: `${NAME}-mini-stats-value`, text: value })
]),
Dom.make('div', { className: `${NAME}-mini-stats-meter-track` }, [
Dom.make('span', { className: `${NAME}-mini-stats-meter-fill`, style: `width:${width}%` })
])
]);
};
return Dom.make('div', { className: `${NAME}-mini-stats-card` }, [
this.statsHeader(topic),
Dom.make('div', { className: `${NAME}-mini-stats-body` }, [
Dom.make('div', { className: `${NAME}-mini-stats-id-row` }, [
row('话题 ID', topic?.id || '-', topic?.id ? {
copyValue: topic.id,
copyTitle: '点击复制话题 ID',
copyMessage: '已复制话题 ID'
} : {}),
Dom.make('span', { className: `${NAME}-mini-stats-status`, text: status })
]),
Dom.make('div', { className: `${NAME}-mini-stats-grid` }, [
row('有效楼层', formatNumber(postsCount)),
row('有效回复', formatNumber(effectiveReplies)),
row('最高楼层', highestPostNumber ? `#${formatNumber(highestPostNumber)}` : '-'),
row('已删除/隐藏楼层', formatNumber(deletedFloors), { warn: deletedFloors > 0 })
]),
meter('楼层有效率', percent(floorValidRate), floorValidRate),
meter('参与密度', percent(participantDensity), participantDensity),
Dom.make('div', { className: `${NAME}-mini-stats-grid` }, [
row('参与人数', formatNumber(s.participantCount)),
row('全帖点赞', formatNumber(s.likeCount)),
row('浏览量', formatNumber(s.views)),
row('话题字数', formatNumber(s.wordCount))
]),
Dom.make('button', {
className: `${NAME}-mini-stats-drawer`,
type: 'button',
'data-mini-stats-action': 'drawer',
text: '抽屉统计'
})
])
]);
},
hideSoon(delay = 220) {
this.cancelHide();
this.timer = window.setTimeout(() => this.hide(), delay);
},
cancelHide() {
if (!this.timer) return;
clearTimeout(this.timer);
this.timer = 0;
},
schedulePreload(topicId) {
this.cancelPreload();
this.preloadTimer = window.setTimeout(() => {
this.preloadTimer = 0;
StarterPostStore.preload(topicId);
}, Prefs.value.previewPrefetchDelay);
},
cancelPreload() {
if (!this.preloadTimer) return;
clearTimeout(this.preloadTimer);
this.preloadTimer = 0;
},
hide() {
this.cancelHide();
this.cancelPreload();
this.hideStats();
if (!this.button) return;
this.button.classList.remove('is-live');
this.button.removeAttribute('data-topic-id');
this.anchor = null;
}
};
// 抽屉关闭后帮助用户定位刚刚查看过的话题。
const LastViewedMarker = {
topicId: '',
anchor: null,
flashTimer: 0,
set(topicId, anchor) {
this.clear({ keepState: false });
this.topicId = String(topicId || '');
this.anchor = this.anchorFor(this.topicId, anchor);
if (!this.anchor) return;
this.ensureBadge(this.anchor);
},
reveal() {
const anchor = this.anchor?.isConnected ? this.anchor : this.findAnchor(this.topicId);
if (!anchor) return;
this.anchor = anchor;
this.ensureBadge(anchor);
const row = TopicBadges.topicRow(anchor);
row?.classList.add(`${NAME}-last-viewed-row`);
clearTimeout(this.flashTimer);
this.flashTimer = window.setTimeout(() => {
row?.classList.remove(`${NAME}-last-viewed-row`);
}, 2200);
try {
anchor.scrollIntoView({ block: 'center', behavior: 'smooth' });
} catch (_) {
anchor.scrollIntoView();
}
},
clear({ keepState } = { keepState: true }) {
clearTimeout(this.flashTimer);
document.querySelectorAll(`.${NAME}-last-viewed-badge`).forEach((badge) => badge.remove());
document.querySelectorAll(`.${NAME}-last-viewed-row`).forEach((row) => row.classList.remove(`${NAME}-last-viewed-row`));
if (!keepState) {
this.topicId = '';
this.anchor = null;
}
},
ensureBadge(anchor) {
if (!anchor?.isConnected) return null;
this.clear({ keepState: true });
const badge = Dom.make('span', {
className: `${NAME}-last-viewed-badge`,
text: '上次查看',
title: '刚才在抽屉里查看的话题'
});
let target = anchor;
while (target.nextElementSibling?.classList?.contains(`${NAME}-read-badge`) ||
target.nextElementSibling?.classList?.contains(`${NAME}-effective-badge`)) {
target = target.nextElementSibling;
}
target.insertAdjacentElement('afterend', badge);
return badge;
},
anchorFor(topicId, sourceAnchor = null) {
const id = String(topicId || '');
if (!id) return null;
return TopicBadges.badgeAnchorFor(sourceAnchor, id, { localOnly: true }) ||
this.localAnchor(sourceAnchor, id) ||
this.findAnchor(id);
},
localAnchor(sourceAnchor, topicId) {
const id = String(topicId || '');
if (!id || !(sourceAnchor instanceof Element) || !sourceAnchor.isConnected) return null;
if (sourceAnchor.matches(LAST_VIEWED_ANCHOR_QUERY) &&
Urls.topicIdFromHref(sourceAnchor.getAttribute('href')) === id) {
return sourceAnchor;
}
const row = TopicBadges.topicRow(sourceAnchor);
return Array.from(row?.querySelectorAll?.(LAST_VIEWED_ANCHOR_QUERY) || []).find((candidate) => {
return !Dom.isOwnSurface(candidate) && Urls.topicIdFromHref(candidate.getAttribute('href')) === id;
}) || null;
},
findAnchor(topicId) {
if (!topicId) return null;
return Array.from(document.querySelectorAll(LAST_VIEWED_ANCHOR_QUERY)).find((anchor) => {
if (Dom.isOwnSurface(anchor)) return false;
return Urls.topicIdFromHref(anchor.getAttribute('href')) === String(topicId);
}) || null;
}
};
// 右侧阅读抽屉。预览模式渲染清理后的首帖 HTML;
// 详情模式通过 iframe 加载 Discourse 楼层视图或话题原始链接。
const Drawer = {
shade: null,
root: null,
main: null,
body: null,
footer: null,
sidebar: null,
activeSidebarPanel: '',
currentStats: null,
topicId: '',
sourceHref: '',
topicTitle: '',
mode: 'summary',
resumeState: null,
job: 0,
lockSnapshot: null,
frameControls: null,
loadedContentCleanup: null,
trackViewTimer: 0,
trackViewCountdownTimer: 0,
trackViewCountdownDuration: 0,
trackViewDeadline: 0,
trackViewStatus: null,
trackViewSource: '',
autoScrollState: 'idle',
autoScrollPauseReason: '',
autoScrollFrame: 0,
autoScrollLastAt: 0,
autoScrollCurrentSpeed: AUTO_SCROLL_DEFAULT_SPEED,
autoScrollTargetSpeed: AUTO_SCROLL_DEFAULT_SPEED,
autoScrollNextSpeedAt: 0,
autoScrollPauseUntil: 0,
autoScrollFrameDoc: null,
autoScrollFrameCleanup: null,
sidebarScrollTop: Object.create(null),
sidebarPointerClickUntil: 0,
ensure() {
if (this.root) return;
this.shade = Dom.make('div', { id: `${NAME}-shade` });
this.shade.addEventListener('click', () => {
if (this.hideSettings()) return;
this.close();
});
document.body.appendChild(this.shade);
this.sidebar = Dom.make('aside', {
id: `${NAME}-drawer-sidebar`,
'aria-label': '抽屉侧边栏'
});
this.sidebar.addEventListener('pointerdown', (event) => this.onSidebarPointerDown(event), true);
this.sidebar.addEventListener('click', (event) => this.onSidebarClick(event));
this.root = Dom.make('aside', {
id: `${NAME}-drawer`,
role: 'dialog',
'aria-modal': 'true',
'aria-label': `${APP_NAME} 话题预览`,
tabindex: '-1'
});
this.root.append(
this.buildHeader(),
this.main = Dom.make('div', { className: `${NAME}-drawer-main` }, [
this.sidebar,
this.body = Dom.make('div', { className: `${NAME}-body` })
]),
this.footer = this.buildFooter()
);
this.root.addEventListener('click', (event) => this.onClick(event));
this.root.addEventListener('input', (event) => this.onInput(event));
this.root.addEventListener('change', (event) => this.onInput(event));
this.root.addEventListener('wheel', (event) => this.pauseAutoScrollForInteraction(event), { passive: true, capture: true });
this.root.addEventListener('pointerdown', (event) => this.pauseAutoScrollForInteraction(event), true);
this.root.addEventListener('touchstart', (event) => this.pauseAutoScrollForInteraction(event), { passive: true, capture: true });
this.root.addEventListener('keydown', (event) => this.pauseAutoScrollForInteraction(event), true);
this.bindFrameFocusRelease(this.sidebar);
this.bindFrameFocusRelease(this.footer);
document.addEventListener('visibilitychange', () => {
if (document.hidden) this.pauseAutoScroll('hidden');
});
window.addEventListener('blur', () => this.pauseAutoScroll('hidden'), { passive: true });
document.body.appendChild(this.root);
},
buildHeader() {
const title = Dom.make('div', { className: `${NAME}-title-block` }, [
Dom.make('div', { className: `${NAME}-title`, text: APP_NAME }),
Dom.make('div', { className: `${NAME}-subtitle`, 'data-role': 'default-mode' })
]);
const brand = Dom.make('div', { className: `${NAME}-brand` }, [
Dom.make('span', { className: `${NAME}-brand-eye`, 'aria-hidden': 'true' }),
title
]);
const modeSwitch = Dom.make('div', { className: `${NAME}-switch`, role: 'group', 'aria-label': '打开方式' }, [
Dom.make('button', { type: 'button', 'data-mode': 'summary', text: '预览' }),
Dom.make('button', { type: 'button', 'data-mode': 'thread', text: '详情' })
]);
const tools = Dom.make('div', { className: `${NAME}-tools` }, [
modeSwitch,
Dom.make('button', { className: `${NAME}-icon-btn`, type: 'button', title: '收藏话题', 'aria-label': '收藏话题', 'aria-pressed': 'false', 'data-action': 'favorite' },
Dom.make('span', { className: `${NAME}-favorite-star`, 'aria-hidden': 'true', text: '☆' })),
Dom.make('button', { className: `${NAME}-icon-btn`, type: 'button', title: '关闭', 'aria-label': '关闭', 'data-action': 'close' },
Dom.make('span', { className: `${NAME}-close`, 'aria-hidden': 'true' }))
]);
return Dom.make('header', { className: `${NAME}-head` }, [brand, tools]);
},
buildFooter() {
return Dom.make('footer', {
className: `${NAME}-drawer-footer`,
'aria-label': '抽屉状态栏'
}, [
Dom.make('div', {
className: `${NAME}-drawer-track`,
'data-role': 'drawer-track',
'data-drawer-footer-slot': 'tracking'
}, [
Dom.make('div', {
className: `${NAME}-drawer-status`,
'data-role': 'topic-status',
'aria-live': 'polite'
}, [
Dom.make('span', { className: `${NAME}-drawer-status-dot`, 'aria-hidden': 'true' }),
Dom.make('span', {
className: `${NAME}-drawer-status-text`,
'data-role': 'topic-status-text',
text: '话题已生效'
})
]),
Dom.make('div', { className: `${NAME}-drawer-track-bar`, 'aria-hidden': 'true' }, [
Dom.make('span', {
className: `${NAME}-drawer-track-fill`,
'data-role': 'topic-status-progress'
})
])
]),
Dom.make('div', {
className: `${NAME}-drawer-footer-actions`,
'data-drawer-footer-slot': 'actions'
}, [
Dom.make('button', {
type: 'button',
className: `${NAME}-drawer-footer-btn ${NAME}-drawer-footer-scroll`,
title: '开始自动滚动',
'aria-label': '开始自动滚动',
'aria-pressed': 'false',
'data-action': 'toggle-auto-scroll',
'data-scroll-state': 'idle'
}, [
Dom.make('span', { className: `${NAME}-drawer-footer-scroll-icon`, 'aria-hidden': 'true' }),
Dom.make('span', { className: `${NAME}-drawer-footer-scroll-label`, 'data-role': 'auto-scroll-label', text: '自动滚动' })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-drawer-footer-btn ${NAME}-drawer-footer-copy`,
title: '复制当前话题地址',
'aria-label': '复制当前话题地址',
'data-action': 'copy-current-url'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-drawer-footer-btn ${NAME}-drawer-footer-open`,
title: '新标签打开当前话题',
'aria-label': '新标签打开当前话题',
'data-action': 'open-current-url'
})
])
]);
},
settingButton(mode, label, desc) {
const active = Prefs.value.mode === mode;
return Dom.make('button', {
type: 'button',
className: `${NAME}-setting${active ? ' is-active' : ''}`,
'aria-pressed': active ? 'true' : 'false',
'data-default-mode': mode
}, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
threadSourceButton(source, label, desc) {
const active = Prefs.value.threadSource === source;
return Dom.make('button', {
type: 'button',
className: `${NAME}-setting${active ? ' is-active' : ''}`,
'aria-pressed': active ? 'true' : 'false',
'data-thread-source': source
}, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
sizeControl(field, label) {
return SizeControls.build(field, label);
},
sidebarTool(action, icon, label, options = {}) {
const active = options.active === true;
const props = {
type: 'button',
className: `${NAME}-sidebar-tool${active ? ' is-active' : ''}${options.dockControl ? ` ${NAME}-sidebar-dock-scroll` : ''}`,
title: label,
'aria-label': label,
'data-sidebar-action': action
};
if (options.pressed !== undefined) {
props['aria-pressed'] = options.pressed ? 'true' : 'false';
}
if (options.disabled) {
props.disabled = 'disabled';
props['aria-disabled'] = 'true';
}
const children = [
Dom.make('span', {
className: `${NAME}-sidebar-tool-icon ${NAME}-sidebar-icon-${icon}`,
'aria-hidden': 'true'
})
];
if (Number(options.count) > 0) {
children.push(Dom.make('span', {
className: `${NAME}-sidebar-tool-count`,
text: String(options.count)
}));
}
return Dom.make('button', props, children);
},
sidebarQueueRows() {
return ReadLaterQueue.items.map((item, index) => {
const active = item.id === this.topicId;
return Dom.make('div', { className: `${NAME}-sidebar-queue-row${active ? ' is-active' : ''}` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-item`,
title: item.title,
'data-sidebar-queue-open': item.id
}, [
Dom.make('span', { className: `${NAME}-sidebar-queue-index`, text: String(index + 1) }),
Dom.make('span', { className: `${NAME}-sidebar-queue-title`, text: item.title })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-remove`,
title: '移出稍后阅读',
'aria-label': `移出稍后阅读:${item.title}`,
'data-sidebar-queue-remove': item.id,
text: '×'
})
]);
});
},
sidebarLiveRows() {
return LiveTopics.topics.map((topic) => {
const active = topic.id === this.topicId;
const unread = LiveTopics.unreadIds.has(topic.id);
return Dom.make('button', {
type: 'button',
className: `${NAME}-live-topic ${NAME}-sidebar-live-topic${active ? ' is-active' : ''}${unread ? ' is-unread' : ''}`,
title: topic.title,
'aria-current': active ? 'true' : undefined,
'data-sidebar-live-open': topic.id
}, [
Dom.make('span', { className: `${NAME}-live-topic-title`, text: topic.title }),
Dom.make('span', { className: `${NAME}-live-topic-meta`, text: `${topic.categoryName} · ${LiveTopics.relativeTime(topic.lastPostedAt)} · ${topic.replies} 回复 · ${topic.views} 浏览` })
]);
});
},
sidebarFavoriteRows() {
return Favorites.items.map((item, index) => {
const active = item.id === this.topicId;
return Dom.make('div', { className: `${NAME}-sidebar-queue-row ${NAME}-sidebar-topic-row${active ? ' is-active' : ''}` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-item ${NAME}-sidebar-topic-item`,
title: item.title,
'data-sidebar-favorite-open': item.id
}, [
Dom.make('span', { className: `${NAME}-sidebar-queue-index`, text: String(index + 1) }),
Dom.make('span', { className: `${NAME}-sidebar-topic-copy` }, [
Dom.make('span', { className: `${NAME}-sidebar-queue-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-sidebar-topic-meta`, text: formatFavoriteTime(item.at) })
])
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-remove`,
title: '移除收藏',
'aria-label': `移除收藏:${item.title}`,
'data-sidebar-favorite-remove': item.id,
text: '×'
})
]);
});
},
sidebarRecentRows() {
return ReadMemory.items.slice(0, Prefs.value.recentLimit).map((item, index) => {
const active = item.id === this.topicId;
return Dom.make('div', { className: `${NAME}-sidebar-queue-row ${NAME}-sidebar-topic-row ${NAME}-sidebar-recent-row${active ? ' is-active' : ''}` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-item ${NAME}-sidebar-topic-item`,
title: `${item.title}\n${item.href || ''}`,
'data-sidebar-recent-open': item.id
}, [
Dom.make('span', { className: `${NAME}-sidebar-queue-index`, text: String(index + 1) }),
Dom.make('span', { className: `${NAME}-sidebar-topic-copy` }, [
Dom.make('span', { className: `${NAME}-sidebar-queue-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-sidebar-topic-meta`, text: formatFavoriteTime(item.readAt || item.at) })
])
])
]);
});
},
queueStepState() {
return {
count: ReadLaterQueue.items.length
};
},
sidebarFrameAction(action, label, desc) {
return Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-frame-action`,
title: label,
'aria-label': label,
'data-sidebar-frame-action': action
}, [
Dom.make('span', { className: `${NAME}-sidebar-frame-label`, text: label }),
Dom.make('span', { className: `${NAME}-sidebar-frame-desc`, text: desc })
]);
},
sidebarWidthPresetAction(preset) {
const active = Prefs.value.width === preset.width;
return Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-frame-action ${NAME}-sidebar-width-preset${active ? ' is-active' : ''}`,
title: `${preset.label}:${preset.width}px`,
'aria-label': `${preset.label}:${preset.width}px`,
'aria-pressed': active ? 'true' : 'false',
'data-sidebar-width-preset': preset.id
}, [
Dom.make('span', { className: `${NAME}-sidebar-frame-label`, text: preset.label }),
Dom.make('span', { className: `${NAME}-sidebar-frame-desc`, text: `${preset.width}px · ${preset.help}` })
]);
},
formatSidebarFrameUrl(url) {
if (!url) return '当前详情地址';
try {
const parsed = new URL(url, location.origin);
return `${parsed.pathname}${parsed.search}${parsed.hash}` || parsed.href;
} catch (_) {
return url;
}
},
currentTopicUrl() {
if (!this.topicId) return '';
return Urls.absolute(this.sourceHref || Urls.canonicalTopic(this.topicId));
},
trackSourceLabel(source) {
return {
live: '实时列表',
topicLinks: '话题入口',
readLater: '稍后阅读',
recent: '最近查看',
resume: '继续抽屉',
favorites: '收藏话题'
}[source] || '抽屉';
},
applyWidthPreset(presetId) {
const preset = DRAWER_WIDTH_PRESETS.find((item) => item.id === presetId);
if (!preset) return;
this.persistSizeValue('width', preset.width);
showToast(`抽屉宽度:${preset.label}`);
},
open(topicId, mode, sourceAnchor = null, sourceHref = '', options = {}) {
this.ensure();
const requestedSidebarPanel = Object.prototype.hasOwnProperty.call(options, 'sidebarPanel')
? options.sidebarPanel
: undefined;
LastViewedMarker.set(topicId, sourceAnchor);
this.topicId = topicId;
this.sourceHref = sourceHref || sourceAnchor?.getAttribute?.('href') || this.sourceHref || '';
this.topicTitle = Favorites.bestTitle(
topicId,
options.title,
sourceAnchor,
Favorites.get(topicId)?.title || ReadMemory.get(topicId)?.title
);
this.mode = Prefs.mode(mode);
this.resumeState = options.resumeState?.topicId === String(topicId) ? options.resumeState : null;
this.trackViewSource = this.normalizeTrackSource(options.trackSource);
this.job += 1;
this.releaseLoadedContent();
this.applyTheme();
this.applySize();
ReadMemory.remember(topicId, sourceAnchor, this.sourceHref, this.topicTitle, {
preserveOrder: options.preserveRecentOrder === true
});
this.lockPage();
this.shade.classList.add('is-open');
this.root.classList.add('is-open');
this.hideSettings();
if (requestedSidebarPanel !== undefined) this.activeSidebarPanel = requestedSidebarPanel || '';
Favorites.updateMeta(topicId, sourceAnchor, this.sourceHref, this.topicTitle);
this.saveDrawerState();
this.syncControls();
this.renderSidebar();
this.scheduleTrackView();
if (this.mode === 'thread') this.paintThread(topicId);
else this.paintSummary(topicId);
},
scheduleTrackView() {
this.cancelTrackView();
if (!this.topicId || this.root?.classList.contains('is-open') !== true) {
this.setTrackViewStatus(null);
return;
}
const disabledStatus = this.trackViewDisabledStatus();
if (disabledStatus) {
this.setTrackViewStatus(disabledStatus);
return;
}
const topicId = String(this.topicId);
const href = this.sourceHref || Urls.canonicalTopic(topicId);
const source = this.trackViewSource || 'drawer-open';
const job = this.job;
const delay = this.trackViewDelay();
this.startTrackViewCountdown(delay);
this.trackViewTimer = window.setTimeout(() => {
this.trackViewTimer = 0;
this.stopTrackViewCountdown();
const currentDisabledStatus = this.trackViewDisabledStatus();
if (currentDisabledStatus) {
this.setTrackViewStatus(currentDisabledStatus);
return;
}
if (job !== this.job || this.topicId !== topicId || this.root?.classList.contains('is-open') !== true) return;
this.setTrackViewStatus({ type: 'sending', text: '正在发送阅读计数...', progress: 1 });
DiscourseReadTracker.markTopic(topicId, href, source).then((result) => {
if (job !== this.job || this.topicId !== topicId || this.root?.classList.contains('is-open') !== true) return;
if (result?.skipped) {
this.setTrackViewStatus({
type: 'done',
text: result.reason === 'starter-post-read-with-browser-pageview'
? '话题计数原已生效'
: result.reason === 'recent-confirmed-refill'
? '补发后话题计数已生效'
: '阅读计数近期已处理',
progress: 1
});
} else if (result?.accepted) {
this.setTrackViewStatus({
type: result.confirmed ? 'done' : 'sent',
text: result.confirmed ? '补发后话题计数已生效' : '阅读计数已发送',
progress: 1
});
} else {
this.setTrackViewStatus({ type: 'error', text: '阅读计数发送失败', progress: 1 });
}
}).catch(() => {
if (job !== this.job || this.topicId !== topicId || this.root?.classList.contains('is-open') !== true) return;
this.setTrackViewStatus({ type: 'error', text: '阅读计数发送失败', progress: 1 });
});
}, delay);
},
normalizeTrackSource(source) {
const value = String(source || '');
return ['live', 'topicLinks', 'readLater', 'recent', 'resume', 'favorites'].includes(value) ? value : '';
},
canTrackCurrentSource() {
return !this.trackViewDisabledStatus();
},
trackViewDisabledStatus() {
if (!Prefs.value.trackDrawerViews) {
return {
type: 'disabled',
text: '抽屉计入后台未开启',
progress: 0,
showProgress: false
};
}
const field = this.trackSourcePref(this.trackViewSource);
if (!field || Prefs.value[field] === false) {
return {
type: 'disabled',
text: '当前入口未计入后台',
progress: 0,
showProgress: false
};
}
return null;
},
trackSourcePref(source) {
return {
live: 'trackDrawerViewsLive',
topicLinks: 'trackDrawerViewsTopicLinks',
readLater: 'trackDrawerViewsReadLater',
recent: 'trackDrawerViewsRecent',
resume: 'trackDrawerViewsResume',
favorites: 'trackDrawerViewsFavorites'
}[source] || '';
},
trackViewDelay() {
const min = DRAWER_TRACK_VIEW_DELAY_MIN;
const max = Math.max(min, DRAWER_TRACK_VIEW_DELAY_MAX);
return Math.round(min + Math.random() * (max - min));
},
cancelTrackView() {
if (this.trackViewTimer) {
window.clearTimeout(this.trackViewTimer);
this.trackViewTimer = 0;
}
this.stopTrackViewCountdown();
this.setTrackViewStatus(null);
},
startTrackViewCountdown(delay) {
this.stopTrackViewCountdown();
this.trackViewCountdownDuration = Math.max(0, Number(delay) || 0);
this.trackViewDeadline = Date.now() + this.trackViewCountdownDuration;
this.updateTrackViewCountdown();
this.trackViewCountdownTimer = window.setInterval(() => this.updateTrackViewCountdown(), 250);
},
stopTrackViewCountdown() {
if (this.trackViewCountdownTimer) {
window.clearInterval(this.trackViewCountdownTimer);
this.trackViewCountdownTimer = 0;
}
this.trackViewDeadline = 0;
this.trackViewCountdownDuration = 0;
},
updateTrackViewCountdown() {
if (!this.trackViewDeadline) return;
const remaining = Math.max(0, this.trackViewDeadline - Date.now());
const seconds = Math.ceil(remaining / 100) / 10;
const duration = Math.max(1, this.trackViewCountdownDuration || remaining || 1);
this.setTrackViewStatus({
type: 'pending',
text: `阅读计数将在 ${seconds.toFixed(1)} 秒后发送`,
remainingMs: remaining,
progress: 1 - remaining / duration
});
if (remaining <= 0) this.stopTrackViewCountdown();
},
setTrackViewStatus(status) {
this.trackViewStatus = status;
this.syncTopicStatus();
},
close() {
const shouldRevealLastViewed = !!this.topicId;
this.job += 1;
this.stopAutoScroll('closed');
this.cancelTrackView();
this.topicId = '';
this.sourceHref = '';
this.topicTitle = '';
this.resumeState = null;
this.trackViewSource = '';
this.trackViewStatus = null;
this.activeSidebarPanel = '';
this.currentStats = null;
this.shade?.classList.remove('is-open');
this.root?.classList.remove('is-open');
this.sidebar?.classList.remove('is-open');
this.releaseLoadedContent();
this.unlockPage();
FloatingPrefs.syncMemoryMonitor?.();
if (shouldRevealLastViewed) LastViewedMarker.reveal();
},
releaseLoadedContent() {
this.stopAutoScroll('content');
this.cleanupAutoScrollFrame();
const cleanup = this.loadedContentCleanup;
this.loadedContentCleanup = null;
if (typeof cleanup === 'function') {
try {
cleanup();
} catch (error) {
console.warn(`${LOG_PREFIX} 抽屉内容清理失败`, error);
}
}
this.frameControls = null;
if (this.activeSidebarPanel === 'frameTools') this.activeSidebarPanel = '';
if (!this.body) return;
this.body.querySelectorAll('iframe').forEach((frame) => {
try {
frame.src = 'about:blank';
frame.removeAttribute('src');
} catch (_) {
// 尽力释放 iframe,降低关闭抽屉后的资源占用。
}
});
this.body.replaceChildren();
},
onClick(event) {
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'close') {
this.close();
return;
}
if (action === 'toggle-auto-scroll') {
this.toggleAutoScroll();
return;
}
if (action === 'favorite') {
if (!this.topicId) return;
const result = Favorites.toggle(this.topicId, LastViewedMarker.anchor, this.sourceHref, this.topicTitle);
this.syncControls();
FloatingPrefs.sync();
showToast(result.active ? '已收藏话题' : '已取消收藏');
return;
}
if (action === 'retry-current') {
if (this.topicId) this.open(this.topicId, this.mode, LastViewedMarker.anchor, this.sourceHref, { trackSource: this.trackViewSource });
return;
}
if (action === 'copy-current-url') {
copyText(this.currentTopicUrl(), '已复制链接');
return;
}
if (action === 'open-current-url') {
const href = this.currentTopicUrl();
if (!href) return;
const opened = window.open(href, '_blank', 'noopener,noreferrer');
if (opened) opened.opener = null;
return;
}
const summaryTab = event.target.closest('[data-summary-tab]')?.dataset.summaryTab;
if (summaryTab) {
SummaryCard.activate(event.target.closest(`.${NAME}-summary`), summaryTab);
return;
}
const summaryStep = event.target.closest('[data-summary-search-step]')?.dataset.summarySearchStep;
if (summaryStep) {
SummaryCard.step(event.target.closest(`.${NAME}-summary`), Number(summaryStep));
return;
}
if (event.target.closest('[data-summary-search-clear]')) {
SummaryCard.clearSearch(event.target.closest(`.${NAME}-summary`));
return;
}
const mode = event.target.closest('[data-mode]')?.dataset.mode;
if (mode) {
Prefs.save({ mode });
FloatingPrefs.sync();
if (this.topicId) this.open(this.topicId, mode, LastViewedMarker.anchor, this.sourceHref, { trackSource: this.trackViewSource });
return;
}
const defaultMode = event.target.closest('[data-default-mode]')?.dataset.defaultMode;
if (defaultMode) {
Prefs.save({ mode: defaultMode });
this.hideSettings();
FloatingPrefs.sync();
if (this.topicId) this.open(this.topicId, defaultMode, LastViewedMarker.anchor, this.sourceHref, { trackSource: this.trackViewSource });
return;
}
const threadSource = event.target.closest('[data-thread-source]')?.dataset.threadSource;
if (threadSource) {
Prefs.save({ threadSource });
this.syncControls();
FloatingPrefs.sync();
if (this.topicId && this.mode === 'thread') {
this.open(this.topicId, this.mode, LastViewedMarker.anchor, this.sourceHref, { trackSource: this.trackViewSource });
}
return;
}
const autoScrollSpeed = event.target.closest('[data-auto-scroll-speed]')?.dataset.autoScrollSpeed;
if (autoScrollSpeed) {
Prefs.save({ autoScrollSpeed });
this.applyAutoScrollSpeedPreference();
this.syncControls();
FloatingPrefs.sync();
showToast(`自动滚动速度:${AUTO_SCROLL_SPEED_LEVELS[Prefs.value.autoScrollSpeed].label}`);
return;
}
const sizeStep = event.target.closest('[data-size-step]');
if (sizeStep) {
const next = SizeControls.fromStep(sizeStep);
if (next) this.persistSizeValue(next.field, next.value);
return;
}
const sizeTrack = event.target.closest('[data-size-track]');
if (sizeTrack) {
const next = SizeControls.fromTrack(sizeTrack, event.clientX);
if (next) this.persistSizeValue(next.field, next.value);
return;
}
},
bindFrameFocusRelease(surface) {
if (!surface) return;
const release = () => this.releaseFrameFocus();
surface.addEventListener('pointerover', release, true);
surface.addEventListener('mouseover', release, true);
surface.addEventListener('focusin', release, true);
},
releaseFrameFocus() {
const active = document.activeElement;
if (!(active instanceof HTMLElement) || active.tagName !== 'IFRAME' || !this.body?.contains(active)) return false;
try {
active.contentWindow?.blur?.();
} catch (_) {
// 跨文档焦点释放失败时,仍继续释放 iframe 元素本身。
}
try {
active.blur();
} catch (_) {
// 个别浏览器可能不允许直接 blur iframe,后续 focus 抽屉兜底。
}
try {
window.focus();
} catch (_) {
// window.focus 不是关键路径,失败时忽略。
}
try {
this.root?.focus?.({ preventScroll: true });
} catch (_) {
this.root?.focus?.();
}
return true;
},
sidebarActionTarget(target) {
if (!(target instanceof Element)) return null;
return target.closest([
'[data-sidebar-copy]',
'[data-sidebar-action]',
'[data-sidebar-live-action]',
'[data-sidebar-frame-action]',
'[data-sidebar-width-preset]',
'[data-sidebar-queue-remove]',
'[data-sidebar-queue-clear]',
'[data-sidebar-page-nav-clear]',
'[data-sidebar-page-nav-add]',
'[data-sidebar-page-nav-defaults]',
'[data-sidebar-page-nav-edit]',
'[data-sidebar-page-nav-blank]',
'[data-sidebar-page-nav-remove]',
'[data-sidebar-page-nav-open]',
'[data-sidebar-favorite-remove]',
'[data-sidebar-favorite-open]',
'[data-sidebar-recent-open]',
'[data-sidebar-live-open]',
'[data-sidebar-queue-open]',
`.${NAME}-sidebar-settings-panel [data-pref-action]`,
`.${NAME}-sidebar-settings-panel [data-pref-tab-scroll]`,
`.${NAME}-sidebar-settings-panel [data-pref-tab]`,
`.${NAME}-sidebar-settings-panel [data-live-category-token]`,
`.${NAME}-sidebar-settings-panel [data-pref-toggle]`,
`.${NAME}-sidebar-settings-panel [data-default-mode]`,
`.${NAME}-sidebar-settings-panel [data-thread-source]`,
`.${NAME}-sidebar-settings-panel [data-auto-scroll-speed]`,
`.${NAME}-sidebar-settings-panel [data-size-step]`,
`.${NAME}-sidebar-settings-panel [data-tuning-step]`,
`.${NAME}-sidebar-settings-panel [data-size-track]`,
`.${NAME}-sidebar-settings-panel [data-queue-remove]`,
`.${NAME}-sidebar-settings-panel [data-queue-open]`,
`.${NAME}-sidebar-settings-panel [data-page-nav-open]`,
`.${NAME}-sidebar-settings-panel [data-page-nav-blank]`,
`.${NAME}-sidebar-settings-panel [data-page-nav-edit]`,
`.${NAME}-sidebar-settings-panel [data-page-nav-remove]`,
`.${NAME}-sidebar-settings-panel [data-favorite-remove]`,
`.${NAME}-sidebar-settings-panel [data-favorite-open]`,
`.${NAME}-sidebar-settings-panel [data-recent-open]`
].join(','));
},
onSidebarPointerDown(event) {
if (event.button !== 0 || event.pointerType === 'touch') return;
if (!(event.target instanceof Element)) return;
if (event.target.closest('input, textarea, select, [contenteditable="true"]')) return;
const target = this.sidebarActionTarget(event.target);
if (!target) return;
const control = target.closest('button, a, [role="button"]');
if (control?.matches?.(':disabled, [aria-disabled="true"]')) return;
this.releaseFrameFocus();
this.sidebarPointerClickUntil = Date.now() + 600;
event.preventDefault();
event.stopPropagation();
this.onSidebarClick(event);
},
shouldSkipSidebarClick(event) {
if (!this.sidebarPointerClickUntil) return false;
if (Date.now() > this.sidebarPointerClickUntil) {
this.sidebarPointerClickUntil = 0;
return false;
}
if (!this.sidebarActionTarget(event.target)) return false;
this.sidebarPointerClickUntil = 0;
event.preventDefault();
event.stopPropagation();
return true;
},
hideSettings() {
if (this.activeSidebarPanel !== 'settings') return false;
this.activeSidebarPanel = '';
this.renderSidebar();
FloatingPrefs.syncMemoryMonitor?.();
return true;
},
onInput(event) {
if (event.target.closest(`.${NAME}-sidebar-settings-panel`)) {
FloatingPrefs.onInput(event);
return;
}
const summarySearch = event.target.closest('[data-summary-search]');
if (summarySearch) {
SummaryCard.search(summarySearch.closest(`.${NAME}-summary`), summarySearch.value);
return;
}
const sizeInput = event.target.closest('[data-size-input]');
if (!sizeInput) return;
const next = event.type === 'input'
? SizeControls.fromTyping(sizeInput)
: SizeControls.fromInput(sizeInput);
if (next) this.persistSizeValue(next.field, next.value);
},
onSidebarClick(event) {
if (event.type === 'click' && this.shouldSkipSidebarClick(event)) return;
const copyTarget = event.target.closest('[data-sidebar-copy]');
if (copyTarget) {
event.preventDefault();
copyText(copyTarget.getAttribute('data-sidebar-copy'), copyTarget.getAttribute('data-sidebar-copy-message') || '已复制');
return;
}
const action = event.target.closest('[data-sidebar-action]')?.dataset.sidebarAction;
if (action === 'dockScrollUp' || action === 'dockScrollDown') {
event.preventDefault();
const stack = this.sidebar?.querySelector(`.${NAME}-sidebar-tool-stack`);
stack?.scrollBy({ top: action === 'dockScrollDown' ? 90 : -90, behavior: 'smooth' });
return;
}
if (action === 'settings') {
event.preventDefault();
this.activeSidebarPanel = this.activeSidebarPanel === 'settings' ? '' : 'settings';
this.renderSidebar();
FloatingPrefs.sync();
return;
}
if (event.target.closest(`.${NAME}-sidebar-settings-panel`)) {
FloatingPrefs.onClick(event);
return;
}
if (action === 'live') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'live' ? '' : 'live';
this.renderSidebar();
if (this.activeSidebarPanel === 'live' && !LiveTopics.topics.length && !LiveTopics.inFlight) {
LiveTopics.fetchNow({ manual: true });
}
return;
}
if (action === 'reloadCurrent') {
event.preventDefault();
if (this.topicId) this.open(this.topicId, this.mode || Prefs.value.mode, LastViewedMarker.anchor, this.sourceHref, { trackSource: this.trackViewSource });
return;
}
if (action === 'readLater') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'readLater' ? '' : 'readLater';
this.renderSidebar();
return;
}
if (action === 'pageNav') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'pageNav' ? '' : 'pageNav';
this.renderSidebar();
return;
}
if (action === 'favorites') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'favorites' ? '' : 'favorites';
this.renderSidebar();
return;
}
if (action === 'topicStats') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'topicStats' ? '' : 'topicStats';
this.renderSidebar();
return;
}
if (action === 'recent') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'recent' ? '' : 'recent';
this.renderSidebar();
return;
}
if (action === 'frameTools') {
event.preventDefault();
if (!this.frameControls) return;
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'frameTools' ? '' : 'frameTools';
this.renderSidebar();
return;
}
if (action === 'widthPresets') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'widthPresets' ? '' : 'widthPresets';
this.renderSidebar();
return;
}
if (action === 'support') {
event.preventDefault();
this.hideSettings();
this.activeSidebarPanel = this.activeSidebarPanel === 'support' ? '' : 'support';
this.renderSidebar();
return;
}
if (action === 'openOriginal') {
event.preventDefault();
const href = Urls.absolute(this.sourceHref || Urls.canonicalTopic(this.topicId));
if (!href) return;
const opened = window.open(href, '_blank', 'noopener,noreferrer');
if (opened) opened.opener = null;
return;
}
if (action === 'close') {
event.preventDefault();
this.close();
return;
}
const liveAction = event.target.closest('[data-sidebar-live-action]')?.dataset.sidebarLiveAction;
if (liveAction === 'refresh') {
event.preventDefault();
LiveTopics.fetchNow({ manual: true });
return;
}
if (liveAction === 'close') {
event.preventDefault();
this.activeSidebarPanel = '';
this.renderSidebar();
return;
}
const frameAction = event.target.closest('[data-sidebar-frame-action]')?.dataset.sidebarFrameAction;
if (frameAction) {
event.preventDefault();
this.frameControls?.run(frameAction);
return;
}
const widthPreset = event.target.closest('[data-sidebar-width-preset]')?.dataset.sidebarWidthPreset;
if (widthPreset) {
event.preventDefault();
this.applyWidthPreset(widthPreset);
return;
}
const removeId = event.target.closest('[data-sidebar-queue-remove]')?.dataset.sidebarQueueRemove;
if (removeId) {
event.preventDefault();
if (!ReadLaterQueue.remove(removeId)) return;
this.syncReadLaterState();
FloatingPrefs.syncQueueState();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
showToast('已移出稍后阅读');
return;
}
const clear = event.target.closest('[data-sidebar-queue-clear]');
if (clear) {
event.preventDefault();
if (!ReadLaterQueue.items.length) return;
if (!window.confirm('确定清空稍后阅读队列吗?')) return;
if (!ReadLaterQueue.clear()) return;
this.syncReadLaterState();
FloatingPrefs.syncQueueState();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
showToast('已清空稍后阅读队列');
return;
}
const clearPageNav = event.target.closest('[data-sidebar-page-nav-clear]');
if (clearPageNav) {
event.preventDefault();
if (!PageNav.items.length) return;
if (!window.confirm('确定清空页面导航吗?')) return;
PageNav.clear();
this.renderSidebar();
FloatingPrefs.sync();
showToast('已清空页面导航');
return;
}
const addPageNav = event.target.closest('[data-sidebar-page-nav-add]');
if (addPageNav) {
event.preventDefault();
const result = PageNav.addCurrent();
this.renderSidebar();
FloatingPrefs.sync();
showToast(!result?.item ? '当前页面无法添加' : result.existing ? '当前页面已在导航中' : '已添加当前页面');
return;
}
const restorePageNav = event.target.closest('[data-sidebar-page-nav-defaults]');
if (restorePageNav) {
event.preventDefault();
PageNav.restoreDefaults();
this.renderSidebar();
FloatingPrefs.sync();
showToast('已恢复默认导航');
return;
}
const editPageNav = event.target.closest('[data-sidebar-page-nav-edit]')?.dataset.sidebarPageNavEdit;
if (editPageNav) {
event.preventDefault();
const result = PageNav.editWithPrompt(editPageNav);
if (result.cancelled) return;
showToast(PageNav.editResultMessage(result));
return;
}
const blankPageNav = event.target.closest('[data-sidebar-page-nav-blank]')?.dataset.sidebarPageNavBlank;
if (blankPageNav) {
event.preventDefault();
PageNav.open(blankPageNav, { newTab: true });
return;
}
const removePageNav = event.target.closest('[data-sidebar-page-nav-remove]')?.dataset.sidebarPageNavRemove;
if (removePageNav) {
event.preventDefault();
const next = PageNav.isCurrent(removePageNav) ? PageNav.neighbor(removePageNav) : null;
if (!PageNav.remove(removePageNav)) return;
this.renderSidebar();
FloatingPrefs.sync();
showToast('已关闭导航标签');
if (next) window.location.assign(next.href);
return;
}
const openPageNav = event.target.closest('[data-sidebar-page-nav-open]')?.dataset.sidebarPageNavOpen;
if (openPageNav) {
event.preventDefault();
PageNav.open(openPageNav);
return;
}
const removeFavorite = event.target.closest('[data-sidebar-favorite-remove]')?.dataset.sidebarFavoriteRemove;
if (removeFavorite) {
event.preventDefault();
Favorites.remove(removeFavorite);
this.syncControls();
FloatingPrefs.sync();
showToast('已移除收藏');
return;
}
const openFavorite = event.target.closest('[data-sidebar-favorite-open]')?.dataset.sidebarFavoriteOpen;
if (openFavorite) {
event.preventDefault();
const item = Favorites.get(openFavorite);
if (!item || item.id === this.topicId) return;
this.open(item.id, this.mode || Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'favorites',
trackSource: 'favorites'
});
return;
}
const openRecent = event.target.closest('[data-sidebar-recent-open]')?.dataset.sidebarRecentOpen;
if (openRecent) {
event.preventDefault();
const item = ReadMemory.get(openRecent);
if (!item || item.id === this.topicId) return;
this.open(item.id, this.mode || Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'recent',
preserveRecentOrder: true,
trackSource: 'recent'
});
return;
}
const openLive = event.target.closest('[data-sidebar-live-open]')?.dataset.sidebarLiveOpen;
if (openLive) {
event.preventDefault();
const topic = LiveTopics.topics.find((item) => item.id === openLive);
if (!topic || topic.id === this.topicId) return;
LiveTopics.unreadIds.delete(topic.id);
LiveTopics.syncButton();
this.open(topic.id, this.mode || Prefs.value.mode, null, topic.href, {
title: topic.title,
sidebarPanel: 'live',
trackSource: 'live'
});
return;
}
const openId = event.target.closest('[data-sidebar-queue-open]')?.dataset.sidebarQueueOpen;
if (!openId) return;
event.preventDefault();
const item = ReadLaterQueue.get(openId);
if (!item || item.id === this.topicId) return;
this.open(item.id, this.mode || Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'readLater',
trackSource: 'readLater'
});
},
syncControls() {
if (!this.root) return;
this.root.querySelectorAll('[data-mode]').forEach((button) => {
const active = button.dataset.mode === this.mode;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
this.root.querySelectorAll('[data-default-mode]').forEach((button) => {
const active = button.dataset.defaultMode === Prefs.value.mode;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
this.root.querySelectorAll('[data-thread-source]').forEach((button) => {
const active = button.dataset.threadSource === Prefs.value.threadSource;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
this.root.querySelectorAll('[data-auto-scroll-speed]').forEach((button) => {
const active = button.dataset.autoScrollSpeed === Prefs.value.autoScrollSpeed;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
const subtitle = this.root.querySelector('[data-role="default-mode"]');
const threadSourceLabel = THREAD_SOURCES[Prefs.value.threadSource].label;
const autoScrollLabel = AUTO_SCROLL_SPEED_LEVELS[Prefs.value.autoScrollSpeed]?.label || AUTO_SCROLL_SPEED_LEVELS[AUTO_SCROLL_DEFAULT_SPEED_LEVEL].label;
if (subtitle) subtitle.textContent = `默认:${MODES[Prefs.value.mode].label}模式 · 详情:${threadSourceLabel} · 滚动:${autoScrollLabel} · ${Prefs.value.width}x${Prefs.value.height}vh`;
const favoriteButton = this.root.querySelector('[data-action="favorite"]');
if (favoriteButton) {
const active = Favorites.has(this.topicId);
favoriteButton.classList.toggle('is-favorite-active', active);
favoriteButton.setAttribute('aria-pressed', active ? 'true' : 'false');
favoriteButton.setAttribute('title', active ? '取消收藏话题' : '收藏话题');
favoriteButton.setAttribute('aria-label', active ? '取消收藏话题' : '收藏话题');
const star = favoriteButton.querySelector(`.${NAME}-favorite-star`);
if (star) star.textContent = active ? '★' : '☆';
}
this.syncTopicStatus();
this.syncFooterActions();
if (this.activeSidebarPanel === 'settings') {
const count = this.sidebar?.querySelector(`.${NAME}-sidebar-settings-panel .${NAME}-sidebar-panel-count`);
if (count) count.textContent = `${Prefs.value.width}px`;
} else {
this.renderSidebar();
}
SizeControls.sync(this.root);
},
renderSidebar() {
if (!this.sidebar) return;
const drawerOpen = this.root?.classList.contains('is-open') === true && !!this.topicId;
if (!drawerOpen) {
this.sidebar.classList.remove(
'is-open',
'is-panel-open',
'is-settings-panel',
'is-page-nav-panel',
'is-read-later-panel',
'is-favorites-panel',
'is-recent-panel',
'is-frame-tools-panel',
'is-width-presets-panel',
'is-support-panel',
'is-topic-stats-panel',
'is-live-panel'
);
this.sidebar.replaceChildren();
return;
}
if (this.activeSidebarPanel === 'frameTools' && !this.frameControls) this.activeSidebarPanel = '';
const scrollState = this.captureSidebarScroll();
const queueState = this.queueStepState();
const count = queueState.count;
const settingsOpen = this.activeSidebarPanel === 'settings';
const readLaterOpen = this.activeSidebarPanel === 'readLater';
const pageNavOpen = this.activeSidebarPanel === 'pageNav';
const favoritesOpen = this.activeSidebarPanel === 'favorites';
const recentOpen = this.activeSidebarPanel === 'recent';
const liveOpen = this.activeSidebarPanel === 'live';
const topicStatsOpen = this.activeSidebarPanel === 'topicStats';
const frameToolsOpen = this.activeSidebarPanel === 'frameTools';
const widthPresetsOpen = this.activeSidebarPanel === 'widthPresets';
const supportOpen = this.activeSidebarPanel === 'support';
const pageNavCount = PageNav.items.length;
const favoritesCount = Favorites.items.length;
const favoriteRows = favoritesOpen ? this.sidebarFavoriteRows() : [];
const recentRows = recentOpen ? this.sidebarRecentRows() : [];
const liveRows = liveOpen ? this.sidebarLiveRows() : [];
const recentCount = Math.min(ReadMemory.items.length, Prefs.value.recentLimit);
const liveCount = LiveTopics.topics.length;
const rows = this.sidebarQueueRows();
const pageRows = PageNav.items.map((item, index) => {
const active = PageNav.isCurrent(item);
const displayHref = PageNav.displayHref(item.href);
return Dom.make('div', { className: `${NAME}-sidebar-queue-row ${NAME}-sidebar-page-row${active ? ' is-active' : ''}` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-item ${NAME}-sidebar-page-item`,
title: `${item.title} · ${displayHref}`,
'aria-current': active ? 'page' : undefined,
'data-sidebar-page-nav-open': item.id
}, [
Dom.make('span', { className: `${NAME}-sidebar-queue-index`, text: String(index + 1) }),
Dom.make('span', { className: `${NAME}-sidebar-queue-title`, text: item.title })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-page-action`,
title: '编辑导航标题和地址',
'aria-label': `编辑导航标题和地址:${item.title}`,
'data-sidebar-page-nav-edit': item.id,
text: '编'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-page-action`,
title: '新标签打开',
'aria-label': `新标签打开:${item.title}`,
'data-sidebar-page-nav-blank': item.id,
text: '↗'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-queue-remove`,
title: '关闭导航标签',
'aria-label': `关闭导航标签:${item.title}`,
'data-sidebar-page-nav-remove': item.id,
text: '×'
})
]);
});
const dockTools = [
this.sidebarTool('support', 'support', supportOpen ? '收起支持我' : '展开支持我', {
active: supportOpen,
pressed: supportOpen
}),
this.sidebarTool('settings', 'settings', settingsOpen ? '收起设置' : '展开设置', {
active: settingsOpen,
pressed: settingsOpen
}),
this.sidebarTool('live', 'live', liveOpen ? '收起实时最新' : '展开实时最新', {
active: liveOpen,
pressed: liveOpen,
count: liveCount
}),
this.sidebarTool('topicStats', 'stats', topicStatsOpen ? '收起话题统计' : '展开话题统计', {
active: topicStatsOpen,
pressed: topicStatsOpen
}),
this.sidebarTool('favorites', 'favorite', favoritesOpen ? '收起收藏话题' : '展开收藏话题', {
active: favoritesOpen,
pressed: favoritesOpen,
count: favoritesCount
}),
this.sidebarTool('recent', 'recent', recentOpen ? '收起最近查看' : '展开最近查看', {
active: recentOpen,
pressed: recentOpen,
count: recentCount
}),
this.sidebarTool('pageNav', 'nav', pageNavOpen ? '收起页面导航' : '展开页面导航', {
active: pageNavOpen,
pressed: pageNavOpen,
count: pageNavCount
}),
this.sidebarTool('widthPresets', 'width', widthPresetsOpen ? '收起宽度预设' : '展开宽度预设', {
active: widthPresetsOpen,
pressed: widthPresetsOpen
}),
this.sidebarTool('reloadCurrent', 'reload', '重新加载当前话题')
];
if (this.mode === 'thread') {
dockTools.push(this.sidebarTool('frameTools', 'tools', frameToolsOpen ? '收起详情工具' : '展开详情工具', {
active: frameToolsOpen,
pressed: frameToolsOpen,
disabled: !this.frameControls
}));
}
dockTools.push(
this.sidebarTool('readLater', 'queue', readLaterOpen ? '收起稍后阅读' : '展开稍后阅读', {
active: readLaterOpen,
pressed: readLaterOpen,
count
}),
this.sidebarTool('openOriginal', 'open', '新标签打开原帖'),
this.sidebarTool('close', 'close', '关闭抽屉')
);
const rail = Dom.make('nav', { className: `${NAME}-sidebar-rail`, 'aria-label': '抽屉工具' }, [
this.sidebarTool('dockScrollUp', 'dock-up', '向上滚动 Dock', { dockControl: true }),
Dom.make('div', { className: `${NAME}-sidebar-tool-stack` }, dockTools),
this.sidebarTool('dockScrollDown', 'dock-down', '向下滚动 Dock', { dockControl: true })
]);
rail.addEventListener('wheel', (event) => {
const stack = rail.querySelector(`.${NAME}-sidebar-tool-stack`);
if (!stack) return;
event.preventDefault();
stack.scrollBy({ top: event.deltaY, behavior: 'auto' });
}, { passive: false });
const settingsPanel = settingsOpen ? FloatingPrefs.buildDockSettingsPanel() : null;
const readLaterPanel = Dom.make('section', { className: `${NAME}-sidebar-panel`, 'aria-label': '稍后阅读', 'data-sidebar-panel': 'readLater' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '稍后阅读' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, 'data-sidebar-queue-count': '1', text: String(count) }),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear`,
title: '清空队列',
'aria-label': '清空队列',
'data-sidebar-queue-clear': '1',
text: '清空'
})
]),
Dom.make('div', { className: `${NAME}-queue-keep-hint ${NAME}-sidebar-queue-hint`, text: READ_LATER_KEEP_HINT }),
Dom.make('div', { className: `${NAME}-sidebar-queue-list`, 'data-sidebar-queue-list': '1' }, rows.length
? rows
: Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '暂无稍后阅读' }))
]);
const pageNavPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-page-panel`, 'aria-label': '页面导航' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '页面导航' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: String(pageNavCount) })
]),
Dom.make('div', { className: `${NAME}-sidebar-panel-actions` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear`,
title: '添加当前页面',
'aria-label': '添加当前页面',
'data-sidebar-page-nav-add': '1',
text: '添加'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear`,
title: '恢复默认导航',
'aria-label': '恢复默认导航',
'data-sidebar-page-nav-defaults': '1',
text: '默认'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear`,
title: '清空导航',
'aria-label': '清空导航',
'data-sidebar-page-nav-clear': '1',
text: '清空'
})
]),
Dom.make('div', { className: `${NAME}-sidebar-queue-list ${NAME}-sidebar-page-list` }, pageRows.length
? pageRows
: Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '暂无页面导航' }))
]);
const favoritesPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-favorites-panel`, 'aria-label': '收藏话题' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '收藏话题' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: String(favoritesCount) })
]),
Dom.make('div', { className: `${NAME}-sidebar-queue-list ${NAME}-sidebar-topic-list` }, favoriteRows.length
? favoriteRows
: Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '暂无收藏话题' }))
]);
const recentPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-recent-panel`, 'aria-label': '最近查看' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '最近查看' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: String(recentCount) })
]),
Dom.make('div', { className: `${NAME}-sidebar-queue-list ${NAME}-sidebar-topic-list` }, recentRows.length
? recentRows
: Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '暂无最近查看' }))
]);
const livePanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-live-panel`, 'aria-label': '实时最新' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '实时最新' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: LiveTopics.inFlight ? '刷新中' : String(liveCount) }),
Dom.make('span', { className: `${NAME}-sidebar-panel-head-actions` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear ${NAME}-sidebar-panel-icon-button ${NAME}-sidebar-live-refresh`,
title: '刷新实时新帖',
'aria-label': '刷新实时新帖',
'data-sidebar-live-action': 'refresh'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-sidebar-panel-clear ${NAME}-sidebar-panel-icon-button ${NAME}-sidebar-live-collapse`,
title: '收起实时最新',
'aria-label': '收起实时最新',
'data-sidebar-live-action': 'close'
})
])
]),
Dom.make('div', { className: `${NAME}-live-status ${NAME}-sidebar-live-status`, text: LiveTopics.statusText() }),
Dom.make('div', { className: `${NAME}-sidebar-queue-list ${NAME}-sidebar-live-list` }, liveRows.length
? liveRows
: Dom.make('div', { className: `${NAME}-sidebar-empty`, text: LiveTopics.inFlight ? '正在刷新最新帖子...' : '暂无匹配的新帖' }))
]);
const currentFrameUrl = this.frameControls?.currentUrl?.() || '';
const frameToolsPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-frame-panel`, 'aria-label': '详情工具' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '详情工具' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: 'URL' })
]),
Dom.make('div', { className: `${NAME}-sidebar-frame-tools` }, [
Dom.make('div', {
className: `${NAME}-sidebar-frame-url`,
title: currentFrameUrl,
text: this.formatSidebarFrameUrl(currentFrameUrl)
}),
this.sidebarFrameAction('jump', '跳转', '地址栏'),
this.sidebarFrameAction('reload', '刷新', '当前详情'),
this.sidebarFrameAction('copy', '复制', '当前链接'),
this.sidebarFrameAction('open', '新标签打开', '当前详情'),
this.sidebarFrameAction('original', '回到原帖', '话题地址'),
this.sidebarFrameAction('initial', '回到初始', THREAD_SOURCES[Prefs.value.threadSource].label)
])
]);
const widthPresetPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-width-panel`, 'aria-label': '抽屉宽度预设' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '抽屉宽度' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: `${Prefs.value.width}px` })
]),
Dom.make('div', { className: `${NAME}-sidebar-frame-tools` },
DRAWER_WIDTH_PRESETS.map((preset) => this.sidebarWidthPresetAction(preset)))
]);
const supportPanel = Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-support-panel`, 'aria-label': '支持我' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '支持我' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: 'LDC' })
]),
Dom.make('div', { className: `${NAME}-sidebar-support-list ${NAME}-support-list` }, supportCards())
]);
const topicStatsPanel = this.buildTopicStatsPanel();
const panel = settingsOpen
? settingsPanel
: topicStatsOpen
? topicStatsPanel
: frameToolsOpen
? frameToolsPanel
: widthPresetsOpen
? widthPresetPanel
: supportOpen
? supportPanel
: liveOpen
? livePanel
: favoritesOpen
? favoritesPanel
: recentOpen
? recentPanel
: pageNavOpen
? pageNavPanel
: readLaterPanel;
this.sidebar.replaceChildren(rail, panel);
this.sidebar.classList.add('is-open');
this.sidebar.classList.toggle('is-panel-open', settingsOpen || readLaterOpen || pageNavOpen || favoritesOpen || recentOpen || liveOpen || frameToolsOpen || widthPresetsOpen || supportOpen || topicStatsOpen);
this.sidebar.classList.toggle('is-settings-panel', settingsOpen);
this.sidebar.classList.toggle('is-topic-stats-panel', topicStatsOpen);
this.sidebar.classList.toggle('is-page-nav-panel', pageNavOpen);
this.sidebar.classList.toggle('is-read-later-panel', readLaterOpen);
this.sidebar.classList.toggle('is-favorites-panel', favoritesOpen);
this.sidebar.classList.toggle('is-recent-panel', recentOpen);
this.sidebar.classList.toggle('is-live-panel', liveOpen);
this.sidebar.classList.toggle('is-frame-tools-panel', frameToolsOpen);
this.sidebar.classList.toggle('is-width-presets-panel', widthPresetsOpen);
this.sidebar.classList.toggle('is-support-panel', supportOpen);
this.bindSidebarScrollTracking();
if (settingsOpen) FloatingPrefs.bindSettingsRoot(settingsPanel);
this.applyTheme();
this.applySize();
this.restoreSidebarScroll(scrollState);
if (settingsOpen) FloatingPrefs.sync();
},
buildTopicStatsPanel() {
const s = this.currentStats;
const statRow = (label, value, options = {}) => {
const props = {
className: `${NAME}-sidebar-stat-row${options.copyValue ? ` ${NAME}-sidebar-stat-copyable` : ''}`
};
if (options.copyValue) {
props.type = 'button';
props.title = options.copyTitle || '点击复制';
props['data-sidebar-copy'] = String(options.copyValue);
props['data-sidebar-copy-message'] = options.copyMessage || '已复制';
}
const row = Dom.make(options.copyValue ? 'button' : 'div', props, [
Dom.make('span', { className: `${NAME}-sidebar-stat-label`, text: label }),
Dom.make('span', {
className: `${NAME}-sidebar-stat-value${options.warn ? ` ${NAME}-sidebar-stat-warn` : ''}`,
text: String(value),
title: String(value)
})
]);
return row;
};
const statGroup = (label, rows) => Dom.make('section', { className: `${NAME}-sidebar-stat-group`, 'aria-label': label }, [
Dom.make('div', { className: `${NAME}-sidebar-stat-group-title`, text: label }),
Dom.make('div', { className: `${NAME}-sidebar-stat-group-body` }, rows)
]);
const meterPercent = (value) => {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(0, Math.min(100, number));
};
const statMeter = (label, value, percent, options = {}) => {
const width = meterPercent(percent);
return Dom.make('div', {
className: `${NAME}-sidebar-stat-meter${options.warn ? ` ${NAME}-sidebar-stat-meter-warn` : ''}`,
role: 'img',
'aria-label': `${label} ${value}`
}, [
Dom.make('div', { className: `${NAME}-sidebar-stat-meter-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-stat-label`, text: label }),
Dom.make('span', {
className: `${NAME}-sidebar-stat-value${options.warn ? ` ${NAME}-sidebar-stat-warn` : ''}`,
text: String(value),
title: String(value)
})
]),
Dom.make('div', { className: `${NAME}-sidebar-stat-meter-track` }, [
Dom.make('span', { className: `${NAME}-sidebar-stat-meter-fill`, style: `width:${width}%` })
])
]);
};
let content;
if (!s) {
content = Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '正在加载统计数据…' });
} else {
const totalFloors = Math.max(0, s.highestPostNumber - 1);
const survivingReplies = Math.max(0, s.postsCount - 1);
const deleted = Math.max(0, totalFloors - survivingReplies);
const deletedPct = totalFloors > 0 ? (deleted / totalFloors * 100).toFixed(1) : '0.0';
const floorValidPct = s.highestPostNumber > 0 ? (s.postsCount / s.highestPostNumber * 100).toFixed(1) : '-';
const participantDensity = s.views > 0 ? (s.participantCount / s.views * 100).toFixed(1) : '-';
const perCapita = s.participantCount > 0 ? (survivingReplies / s.participantCount).toFixed(2) : '-';
const likeRate = s.views > 0 ? (s.likeCount / s.views * 100).toFixed(1) : '-';
const status = s.closed ? '已关闭' : s.archived ? '已归档' : '开放中';
const percentText = (value) => value === '-' ? '-' : `${value}%`;
const starterName = s.starterDisplayName
? `${s.starterDisplayName}${s.starterUsername ? ` @${s.starterUsername}` : ''}`
: s.starterUsername || '-';
const formatTime = (iso) => {
if (!iso) return '-';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
const formatDuration = (startIso, endIso) => {
const start = new Date(startIso);
const end = new Date(endIso);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) return '-';
const minutes = Math.round((end.getTime() - start.getTime()) / 60000);
if (minutes < 60) return `${minutes} 分钟`;
const hours = minutes / 60;
if (hours < 48) return `${hours.toFixed(1)} 小时`;
return `${(hours / 24).toFixed(1)} 天`;
};
content = Dom.make('div', { className: `${NAME}-sidebar-stats-list` }, [
statGroup('整体统计', [
statRow('话题 ID', this.topicId || '-', this.topicId ? {
copyValue: this.topicId,
copyTitle: '点击复制话题 ID',
copyMessage: '已复制话题 ID'
} : {}),
statRow('总楼层数', totalFloors),
statRow('存活回复', survivingReplies),
statMeter('楼层有效率', percentText(floorValidPct), floorValidPct),
statMeter('已删除/隐藏楼层', `${deleted}(${deletedPct}%)`, deletedPct, { warn: deleted > 0 }),
statRow('参与人数', s.participantCount),
statMeter('参与密度', percentText(participantDensity), participantDensity),
statRow('人均发帖', perCapita),
statRow('全帖点赞', s.likeCount),
statMeter('点赞率', percentText(likeRate), likeRate),
statRow('浏览量', s.views),
statRow('全帖总字数', s.wordCount),
statRow('持续时间', formatDuration(s.createdAt, s.lastPostedAt)),
statRow('话题状态', status),
statRow('创建时间', formatTime(s.createdAt)),
statRow('最后回复', formatTime(s.lastPostedAt))
]),
statGroup('楼主 / 主楼', [
statRow('楼主', starterName),
statRow('主楼点赞', Number(s.starterLikeCount || 0)),
statRow('主楼浏览', Number(s.starterReads || 0)),
statRow('主楼回复', Number(s.starterReplyCount || 0)),
statRow('主楼时间', formatTime(s.starterCreatedAt || s.createdAt))
])
]);
}
const survivingCount = s ? Math.max(0, s.postsCount - 1) : '-';
return Dom.make('section', { className: `${NAME}-sidebar-panel ${NAME}-sidebar-topic-stats-panel`, 'aria-label': '话题统计' }, [
Dom.make('div', { className: `${NAME}-sidebar-panel-head` }, [
Dom.make('span', { className: `${NAME}-sidebar-panel-title`, text: '话题统计' }),
Dom.make('span', { className: `${NAME}-sidebar-panel-count`, text: String(survivingCount) })
]),
content
]);
},
captureSidebarScroll() {
if (!this.sidebar) return null;
const panel = this.sidebarPanelFromClassList();
if (!panel) return null;
const scroller = this.sidebarScroller();
if (!scroller) return null;
this.sidebarScrollTop[panel] = scroller.scrollTop;
return { panel, top: scroller.scrollTop };
},
sidebarPanelFromClassList() {
if (!this.sidebar) return '';
if (this.sidebar.classList.contains('is-settings-panel')) return 'settings';
if (this.sidebar.classList.contains('is-page-nav-panel')) return 'pageNav';
if (this.sidebar.classList.contains('is-read-later-panel')) return 'readLater';
if (this.sidebar.classList.contains('is-favorites-panel')) return 'favorites';
if (this.sidebar.classList.contains('is-recent-panel')) return 'recent';
if (this.sidebar.classList.contains('is-live-panel')) return 'live';
if (this.sidebar.classList.contains('is-frame-tools-panel')) return 'frameTools';
if (this.sidebar.classList.contains('is-width-presets-panel')) return 'widthPresets';
if (this.sidebar.classList.contains('is-support-panel')) return 'support';
if (this.sidebar.classList.contains('is-topic-stats-panel')) return 'topicStats';
return '';
},
restoreSidebarScroll(state) {
const panel = this.activeSidebarPanel;
if (!panel) return;
const top = state?.panel === panel && Number.isFinite(state.top)
? state.top
: this.sidebarScrollTop[panel];
if (!Number.isFinite(top)) return;
const scroller = this.sidebarScroller();
if (!scroller) return;
scroller.scrollTop = top;
requestAnimationFrame(() => {
if (scroller.isConnected) scroller.scrollTop = top;
});
},
sidebarScroller() {
return this.sidebar?.querySelector([
`.${NAME}-sidebar-panel .${NAME}-sidebar-queue-list`,
`.${NAME}-sidebar-support-list`,
`.${NAME}-sidebar-stats-list`
].join(',')) || null;
},
bindSidebarScrollTracking() {
const panel = this.activeSidebarPanel;
if (!panel) return;
const scroller = this.sidebarScroller();
if (!scroller) return;
scroller.addEventListener('scroll', () => {
this.sidebarScrollTop[panel] = scroller.scrollTop;
}, { passive: true });
},
syncReadLaterState() {
if (!this.sidebar || this.root?.classList.contains('is-open') !== true) return;
const queueState = this.queueStepState();
this.syncSidebarToolCount('readLater', queueState.count);
const panel = this.sidebar.querySelector('[data-sidebar-panel="readLater"]');
if (!panel) return;
const countNode = panel.querySelector('[data-sidebar-queue-count]');
if (countNode) countNode.textContent = String(queueState.count);
const clearButton = panel.querySelector('[data-sidebar-queue-clear]');
if (clearButton) {
clearButton.disabled = queueState.count <= 0;
clearButton.toggleAttribute('disabled', queueState.count <= 0);
clearButton.setAttribute('aria-disabled', queueState.count > 0 ? 'false' : 'true');
}
const list = panel.querySelector('[data-sidebar-queue-list]');
if (!list) return;
const scrollTop = list.scrollTop;
this.sidebarScrollTop.readLater = scrollTop;
const rows = this.sidebarQueueRows();
list.replaceChildren(...(rows.length
? rows
: [Dom.make('div', { className: `${NAME}-sidebar-empty`, text: '暂无稍后阅读' })]));
list.scrollTop = scrollTop;
requestAnimationFrame(() => {
if (list.isConnected) list.scrollTop = scrollTop;
});
},
syncSidebarActionState(action, enabled) {
const button = this.sidebar?.querySelector(`[data-sidebar-action="${action}"]`);
if (!button) return;
button.disabled = !enabled;
button.toggleAttribute('disabled', !enabled);
button.setAttribute('aria-disabled', enabled ? 'false' : 'true');
},
syncSidebarToolCount(action, count) {
const button = this.sidebar?.querySelector(`[data-sidebar-action="${action}"]`);
if (!button) return;
let badge = button.querySelector(`.${NAME}-sidebar-tool-count`);
if (Number(count) > 0) {
if (!badge) {
badge = Dom.make('span', { className: `${NAME}-sidebar-tool-count` });
button.appendChild(badge);
}
badge.textContent = String(count);
} else {
badge?.remove();
}
},
syncFooterVisibility() {
const footer = this.footer || this.root?.querySelector(`.${NAME}-drawer-footer`);
if (!footer) return;
const hasStatus = footer.querySelector('[data-role="drawer-track"]')?.classList.contains('is-visible') === true;
const hasActions = footer.querySelector('[data-drawer-footer-slot="actions"]')?.classList.contains('is-visible') === true;
footer.classList.toggle('is-visible', hasStatus || hasActions);
},
syncFooterActions() {
if (!this.root) return;
const href = this.currentTopicUrl();
const actions = this.root.querySelector('[data-drawer-footer-slot="actions"]');
actions?.classList.toggle('is-visible', !!href);
actions?.querySelectorAll('button').forEach((button) => {
button.disabled = !href;
button.toggleAttribute('disabled', !href);
button.setAttribute('aria-disabled', href ? 'false' : 'true');
});
this.syncAutoScrollButton();
this.syncFooterVisibility();
},
toggleAutoScroll() {
if (this.autoScrollState === 'running') {
this.pauseAutoScroll('manual');
return;
}
this.startAutoScroll();
},
startAutoScroll() {
if (!this.topicId || this.root?.classList.contains('is-open') !== true) return;
const target = this.autoScrollTarget();
if (!target) {
showToast('当前内容暂时不可自动滚动');
this.stopAutoScroll('unavailable');
return;
}
if (this.autoScrollAtBottom(target)) {
showToast('已到达底部');
this.stopAutoScroll('bottom');
return;
}
this.autoScrollState = 'running';
this.autoScrollPauseReason = '';
this.autoScrollLastAt = 0;
this.autoScrollCurrentSpeed = this.autoScrollSpeedProfile().initial;
this.autoScrollTargetSpeed = this.randomAutoScrollSpeed();
this.autoScrollNextSpeedAt = Date.now() + this.randomAutoScrollSpeedDelay();
this.autoScrollPauseUntil = 0;
this.scheduleAutoScrollFrame();
this.syncAutoScrollButton();
},
pauseAutoScroll(reason = 'manual') {
if (this.autoScrollState !== 'running') return;
this.cancelAutoScrollFrame();
this.autoScrollState = 'paused';
this.autoScrollPauseReason = reason;
this.autoScrollLastAt = 0;
this.autoScrollPauseUntil = 0;
this.syncAutoScrollButton();
},
stopAutoScroll(reason = '') {
this.cancelAutoScrollFrame();
this.autoScrollState = 'idle';
this.autoScrollPauseReason = reason;
this.autoScrollLastAt = 0;
this.autoScrollPauseUntil = 0;
this.syncAutoScrollButton();
},
pauseAutoScrollForInteraction(event) {
if (this.autoScrollState !== 'running') return;
if (event?.isTrusted === false) return;
const target = event?.target instanceof Element ? event.target : null;
if (target?.closest?.('[data-action="toggle-auto-scroll"]')) return;
this.pauseAutoScroll('interaction');
},
scheduleAutoScrollFrame() {
if (this.autoScrollFrame || this.autoScrollState !== 'running') return;
this.autoScrollFrame = window.requestAnimationFrame((time) => this.stepAutoScroll(time));
},
cancelAutoScrollFrame() {
if (!this.autoScrollFrame) return;
window.cancelAnimationFrame(this.autoScrollFrame);
this.autoScrollFrame = 0;
},
stepAutoScroll(time) {
this.autoScrollFrame = 0;
if (this.autoScrollState !== 'running') return;
if (document.hidden) {
this.pauseAutoScroll('hidden');
return;
}
if (this.root?.classList.contains('is-open') !== true) {
this.stopAutoScroll('closed');
return;
}
const target = this.autoScrollTarget();
if (!target) {
this.pauseAutoScroll('unavailable');
return;
}
if (this.autoScrollAtBottom(target)) {
this.stopAutoScroll('bottom');
showToast('自动滚动已到达底部');
return;
}
const now = Date.now();
if (now >= this.autoScrollNextSpeedAt) this.retargetAutoScrollSpeed(now);
if (this.autoScrollPauseUntil && now < this.autoScrollPauseUntil) {
this.autoScrollLastAt = time;
this.scheduleAutoScrollFrame();
return;
}
const previous = this.autoScrollLastAt || time;
const deltaSeconds = Math.max(0, Math.min(0.12, (time - previous) / 1000));
this.autoScrollLastAt = time;
const easing = Math.min(1, deltaSeconds * 1.35);
this.autoScrollCurrentSpeed += (this.autoScrollTargetSpeed - this.autoScrollCurrentSpeed) * easing;
const nextTop = Math.min(
this.autoScrollMax(target),
this.autoScrollTop(target) + this.autoScrollCurrentSpeed * deltaSeconds
);
this.setAutoScrollTop(target, nextTop);
this.scheduleAutoScrollFrame();
},
retargetAutoScrollSpeed(now) {
const profile = this.autoScrollSpeedProfile();
this.autoScrollTargetSpeed = this.randomAutoScrollSpeed();
this.autoScrollNextSpeedAt = now + this.randomAutoScrollSpeedDelay();
const pauseChance = Number.isFinite(profile.pauseChance) ? profile.pauseChance : 0.12;
if (Math.random() < pauseChance) {
this.autoScrollPauseUntil = now + this.randomBetween(AUTO_SCROLL_PAUSE_MIN, AUTO_SCROLL_PAUSE_MAX);
} else {
this.autoScrollPauseUntil = 0;
}
},
randomAutoScrollSpeed() {
const profile = this.autoScrollSpeedProfile();
return this.randomBetween(profile.min, profile.max);
},
autoScrollSpeedProfile() {
return AUTO_SCROLL_SPEED_LEVELS[Prefs.value.autoScrollSpeed] || AUTO_SCROLL_SPEED_LEVELS[AUTO_SCROLL_DEFAULT_SPEED_LEVEL];
},
applyAutoScrollSpeedPreference() {
if (this.autoScrollState !== 'running') return;
const profile = this.autoScrollSpeedProfile();
this.autoScrollCurrentSpeed = Math.max(profile.min, Math.min(profile.max, this.autoScrollCurrentSpeed || profile.initial));
this.autoScrollTargetSpeed = this.randomAutoScrollSpeed();
this.autoScrollNextSpeedAt = Date.now() + this.randomAutoScrollSpeedDelay();
this.autoScrollPauseUntil = 0;
},
randomAutoScrollSpeedDelay() {
const profile = this.autoScrollSpeedProfile();
const min = Number.isFinite(profile.speedChangeMin) ? profile.speedChangeMin : 1400;
const max = Number.isFinite(profile.speedChangeMax) ? profile.speedChangeMax : 4400;
return this.randomBetween(min, max);
},
randomBetween(min, max) {
return min + Math.random() * Math.max(0, max - min);
},
autoScrollTarget() {
if (this.mode === 'thread') {
const frame = this.body?.querySelector(`.${NAME}-frame`);
try {
const doc = frame?.contentDocument || frame?.contentWindow?.document;
const element = doc?.scrollingElement || doc?.documentElement;
if (element && Number(element.scrollHeight) > Number(element.clientHeight) + 2) {
return { element };
}
} catch (_) {
return null;
}
return null;
}
if (!this.body || Number(this.body.scrollHeight) <= Number(this.body.clientHeight) + 2) return null;
return { element: this.body };
},
autoScrollTop(target) {
return Number(target?.element?.scrollTop || 0);
},
setAutoScrollTop(target, value) {
if (!target?.element) return;
target.element.scrollTop = value;
},
autoScrollMax(target) {
const element = target?.element;
if (!element) return 0;
return Math.max(0, Number(element.scrollHeight || 0) - Number(element.clientHeight || 0));
},
autoScrollAtBottom(target) {
return this.autoScrollMax(target) - this.autoScrollTop(target) <= 2;
},
bindAutoScrollFrame(frame) {
this.cleanupAutoScrollFrame();
try {
const doc = frame?.contentDocument || frame?.contentWindow?.document;
if (!doc) return;
const pause = (event) => this.pauseAutoScrollForInteraction(event);
doc.addEventListener('wheel', pause, { passive: true, capture: true });
doc.addEventListener('pointerdown', pause, true);
doc.addEventListener('touchstart', pause, { passive: true, capture: true });
doc.addEventListener('keydown', pause, true);
this.autoScrollFrameDoc = doc;
this.autoScrollFrameCleanup = () => {
doc.removeEventListener('wheel', pause, true);
doc.removeEventListener('pointerdown', pause, true);
doc.removeEventListener('touchstart', pause, true);
doc.removeEventListener('keydown', pause, true);
};
} catch (_) {
this.cleanupAutoScrollFrame();
}
},
cleanupAutoScrollFrame() {
const cleanup = this.autoScrollFrameCleanup;
this.autoScrollFrameCleanup = null;
this.autoScrollFrameDoc = null;
if (typeof cleanup !== 'function') return;
try {
cleanup();
} catch (_) {
// iframe document 可能已经销毁,清理失败不影响抽屉关闭。
}
},
syncAutoScrollButton() {
const button = this.root?.querySelector('[data-action="toggle-auto-scroll"]');
if (!button) return;
const label = button.querySelector('[data-role="auto-scroll-label"]');
const state = this.autoScrollState === 'running'
? 'running'
: this.autoScrollState === 'paused'
? 'paused'
: 'idle';
const text = state === 'running' ? '滚动中' : state === 'paused' ? '已暂停' : '自动滚动';
const title = state === 'running'
? '暂停自动滚动'
: state === 'paused'
? `继续自动滚动${this.autoScrollPauseReasonText() ? `:${this.autoScrollPauseReasonText()}` : ''}`
: '开始自动滚动';
button.dataset.scrollState = state;
button.classList.toggle('is-running', state === 'running');
button.classList.toggle('is-paused', state === 'paused');
button.setAttribute('aria-pressed', state === 'idle' ? 'false' : 'true');
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
if (label) label.textContent = text;
},
autoScrollPauseReasonText() {
return {
manual: '手动暂停',
interaction: '检测到用户操作',
hidden: '页面不可见',
unavailable: '当前内容不可滚动',
bottom: '已到底部'
}[this.autoScrollPauseReason] || '';
},
syncTopicStatus() {
if (!this.root) return;
const status = this.root.querySelector('[data-role="topic-status"]');
const statusText = this.root.querySelector('[data-role="topic-status-text"]');
const track = this.root.querySelector('[data-role="drawer-track"]');
const progress = this.root.querySelector('[data-role="topic-status-progress"]');
if (!status) return;
status.classList.remove('is-pending', 'is-sending', 'is-sent', 'is-done', 'is-error', 'is-disabled');
status.style.removeProperty('--track-progress');
if (progress) progress.style.removeProperty('width');
if (this.trackViewStatus?.text) {
const type = this.trackViewStatus.type || 'sent';
const ratio = Number.isFinite(this.trackViewStatus.progress)
? Math.max(0, Math.min(1, this.trackViewStatus.progress))
: type === 'pending' ? 0 : 1;
const percent = `${Math.round(ratio * 100)}%`;
if (statusText) statusText.textContent = this.trackViewStatus.text;
else status.textContent = this.trackViewStatus.text;
status.title = `来源:${this.trackSourceLabel(this.trackViewSource)}`;
status.classList.add(`is-${this.trackViewStatus.type || 'sent'}`);
status.classList.add('is-visible');
status.style.setProperty('--track-progress', percent);
if (progress) progress.style.width = percent;
track?.classList.add('is-visible');
track?.classList.toggle('has-progress', this.trackViewStatus.showProgress !== false);
this.syncFooterVisibility();
return;
}
const memory = ReadMemory.get(this.topicId);
const visible = Prefs.value.showEffectiveBadges && memory?.effective === true;
if (statusText) statusText.textContent = '话题已生效';
else status.textContent = '话题已生效';
status.title = visible ? 'LinuxDO 小蓝点已消失' : '';
status.classList.toggle('is-visible', visible);
if (progress) progress.style.width = visible ? '100%' : '0%';
track?.classList.toggle('is-visible', visible);
track?.classList.toggle('has-progress', visible);
this.syncFooterVisibility();
},
applyTheme() {
if (!this.root) return;
const dark = pagePrefersDarkTheme();
this.root.classList.toggle('is-light', !dark);
this.sidebar?.classList.toggle('is-light', !dark);
},
applySize() {
if (!this.root) return;
const { width, height } = Prefs.value;
this.root.style.setProperty('--peek-width', `${width}px`);
this.root.style.setProperty('--peek-height', `${height}dvh`);
this.root.style.setProperty('--peek-top', height >= 100 ? '0px' : `calc((100dvh - ${height}dvh) / 2)`);
this.root.classList.toggle('is-partial-height', height < 100);
if (this.sidebar) {
const drawerGap = 12;
const railOffset = 56;
const panelGap = 8;
const viewportPadding = 12;
const drawerLeftReserve = railOffset + panelGap + viewportPadding;
const drawerWidth = Math.min(width, Math.max(0, window.innerWidth - drawerGap - drawerLeftReserve));
const availablePanelWidth = Math.max(160, window.innerWidth - drawerGap - drawerWidth - railOffset - panelGap - viewportPadding);
const panelWidth = Math.min(280, availablePanelWidth, Math.max(190, Math.round(drawerWidth * .32)));
const pagePanelWidth = Math.min(360, availablePanelWidth, Math.max(260, Math.round(drawerWidth * .42)));
const settingsPanelWidth = Math.min(420, availablePanelWidth, Math.max(320, Math.round(drawerWidth * .52)));
this.sidebar.style.setProperty('--sidebar-panel-width', `${panelWidth}px`);
this.sidebar.style.setProperty('--sidebar-page-panel-width', `${pagePanelWidth}px`);
this.sidebar.style.setProperty('--sidebar-settings-panel-width', `${settingsPanelWidth}px`);
}
},
persistSizeValue(field, value) {
Prefs.save({ [field]: SizeControls.clamp(field, value) });
this.applySize();
this.syncControls();
FloatingPrefs.sync();
},
saveDrawerState(extra = {}) {
if (!this.topicId) return;
const mode = Prefs.mode(extra.mode || this.mode);
DrawerState.save({
topicId: this.topicId,
title: this.topicTitle,
sourceHref: this.sourceHref || Urls.canonicalTopic(this.topicId),
mode,
frameUrl: mode === 'thread' ? extra.frameUrl : '',
frameHistory: mode === 'thread' ? extra.frameHistory : [],
frameHistoryIndex: mode === 'thread' ? extra.frameHistoryIndex : 0,
...extra
});
FloatingPrefs.sync();
},
lockPage() {
if (this.lockSnapshot) return;
const html = document.documentElement;
const body = document.body;
const gap = Math.max(0, window.innerWidth - html.clientWidth);
const bodyPadding = parseFloat(getComputedStyle(body).paddingRight) || 0;
this.lockSnapshot = {
htmlOverflow: html.style.overflow,
bodyOverflow: body.style.overflow,
bodyPaddingRight: body.style.paddingRight
};
html.style.overflow = 'hidden';
body.style.overflow = 'hidden';
if (gap) body.style.paddingRight = `${bodyPadding + gap}px`;
},
unlockPage() {
if (!this.lockSnapshot) return;
document.documentElement.style.overflow = this.lockSnapshot.htmlOverflow;
document.body.style.overflow = this.lockSnapshot.bodyOverflow;
document.body.style.paddingRight = this.lockSnapshot.bodyPaddingRight;
this.lockSnapshot = null;
},
busy(message) {
this.body.replaceChildren(Dom.make('div', { className: `${NAME}-state` }, [
Dom.make('span', { className: `${NAME}-spinner`, 'aria-hidden': 'true' }),
Dom.make('span', { text: message })
]));
},
fail(message, actions = []) {
const children = [Dom.make('div', { className: `${NAME}-state-message`, text: message })];
if (actions.length) {
children.push(Dom.make('div', { className: `${NAME}-state-actions` }, actions.map((item) => {
if (item.href) {
return Dom.make('a', {
className: `${NAME}-soft-btn`,
href: item.href,
target: '_blank',
rel: 'noopener noreferrer',
text: item.label
});
}
return Dom.make('button', {
className: `${NAME}-soft-btn`,
type: 'button',
'data-action': item.action,
text: item.label
});
})));
}
this.body.replaceChildren(Dom.make('div', { className: `${NAME}-state ${NAME}-error` }, children));
},
async paintSummary(topicId) {
const ticket = this.job;
this.busy('正在读取楼主正文');
try {
const topic = await StarterPostStore.load(topicId);
if (ticket !== this.job || this.topicId !== topicId || this.mode !== 'summary') return;
this.topicTitle = Favorites.cleanTitle(topic.title) || this.topicTitle;
ReadMemory.updateMeta(topicId, LastViewedMarker.anchor, this.sourceHref, this.topicTitle);
Favorites.updateMeta(topicId, LastViewedMarker.anchor, this.sourceHref, this.topicTitle);
this.saveDrawerState();
this.syncControls();
this.currentStats = topic.stats || null;
this.body.replaceChildren(SummaryCard.build(topic));
this.body.scrollTop = 0;
this.renderSidebar();
} catch (error) {
if (ticket !== this.job) return;
console.warn(`${LOG_PREFIX} 预览读取失败`, error);
const originalUrl = Urls.absolute(this.sourceHref || Urls.canonicalTopic(topicId));
this.fail('读取失败,可能是登录状态、权限或访问频率限制导致。', [
{ label: '重试', action: 'retry-current' },
{ label: '打开原帖', href: originalUrl },
{ label: '复制链接', action: 'copy-current-url' }
]);
}
},
paintThread(topicId) {
const ticket = this.job;
this.currentStats = null;
StarterPostStore.load(topicId).then((topic) => {
if (ticket !== this.job || this.topicId !== topicId) return;
this.currentStats = topic?.stats || null;
this.renderSidebar();
}).catch(() => {});
const initialTopicUrl = Urls.framePreview(this.threadFrameUrl(topicId));
const resume = this.resumeState?.mode === 'thread' && this.resumeState.topicId === String(topicId)
? this.resumeState
: null;
const restoredHistory = (resume?.frameHistory || []).map((href) => Urls.framePreview(href)).filter(Boolean);
const restoredIndex = restoredHistory.length
? Prefs.numberInRange(resume?.frameHistoryIndex, restoredHistory.length - 1, 0, restoredHistory.length - 1)
: 0;
const url = Urls.framePreview(resume?.frameUrl) || restoredHistory[restoredIndex] || initialTopicUrl;
const status = Dom.make('div', { className: `${NAME}-frame-status`, text: '正在加载详情' });
const frame = Dom.make('iframe', {
className: `${NAME}-frame`,
src: url,
title: `${APP_NAME} 详情预览`,
loading: 'eager',
referrerpolicy: 'same-origin'
});
const urlInput = Dom.make('input', {
className: `${NAME}-frame-url-input`,
type: 'text',
value: url,
spellcheck: 'false',
autocomplete: 'off',
'aria-label': '详情 iframe 地址'
});
const backButton = Dom.make('button', {
className: `${NAME}-frame-nav ${NAME}-frame-nav-back`,
type: 'button',
title: '后退,悬浮查看历史',
'aria-label': '后退',
text: '‹'
});
const forwardButton = Dom.make('button', {
className: `${NAME}-frame-nav ${NAME}-frame-nav-forward`,
type: 'button',
title: '前进,悬浮查看历史',
'aria-label': '前进',
text: '›'
});
const navGroup = Dom.make('div', { className: `${NAME}-frame-navs` }, [backButton, forwardButton]);
const historyMenu = Dom.make('div', { className: `${NAME}-frame-history-menu`, role: 'menu' });
const urlForm = Dom.make('form', { className: `${NAME}-frame-url-form` }, [
Dom.make('span', { className: `${NAME}-frame-mode`, text: '详情模式' }),
navGroup,
urlInput
]);
const frameWrap = Dom.make('div', { className: `${NAME}-frame-wrap` }, [status, frame]);
const showStatus = (message, actions = []) => {
status.classList.toggle('has-actions', actions.length > 0);
const children = [Dom.make('div', { className: `${NAME}-frame-status-message`, text: message })];
if (actions.length) {
children.push(Dom.make('div', { className: `${NAME}-frame-status-actions` }, actions.map((item) => {
if (item.href) {
return Dom.make('a', {
className: `${NAME}-soft-btn`,
href: item.href,
target: '_blank',
rel: 'noopener noreferrer',
text: item.label
});
}
return Dom.make('button', {
className: `${NAME}-soft-btn`,
type: 'button',
'data-frame-action': item.action,
text: item.label
});
})));
}
status.replaceChildren(...children);
if (!status.isConnected) frameWrap.prepend(status);
};
let lastFrameUrl = url;
let pendingFrameUrl = '';
let pendingHistoryMode = '';
let pendingHistoryUntil = 0;
let frameUserNavigationUntil = 0;
const frameHistory = restoredHistory.length ? restoredHistory : [url];
let frameHistoryIndex = restoredHistory.length ? restoredIndex : 0;
if (frameHistory[frameHistoryIndex] !== url) frameHistory[frameHistoryIndex] = url;
let historyHideTimer = 0;
let loadTimer = 0;
let frameWatchTimer = 0;
let nudgeTimer = 0;
let frameShell = null;
let disposed = false;
let watchedFrameDoc = null;
let cleanupWatchedFrameDoc = null;
const currentFrameUrl = () => {
return Urls.framePreview(lastFrameUrl || urlInput.value || frame.getAttribute('src') || url) || url;
};
const statusActions = () => [
{ label: '重试', action: 'retry' },
{ label: '新标签打开', href: currentFrameUrl() },
{ label: '复制链接', action: 'copy' }
];
const clearLoadTimer = () => {
if (!loadTimer) return;
window.clearTimeout(loadTimer);
loadTimer = 0;
};
const clearFrameWatchTimer = () => {
if (!frameWatchTimer) return;
window.clearTimeout(frameWatchTimer);
frameWatchTimer = 0;
};
const clearNudgeTimer = () => {
if (!nudgeTimer) return;
window.clearTimeout(nudgeTimer);
nudgeTimer = 0;
};
const releaseWatchedFrameDoc = () => {
const cleanupDoc = cleanupWatchedFrameDoc;
watchedFrameDoc = null;
cleanupWatchedFrameDoc = null;
if (typeof cleanupDoc !== 'function') return;
try {
cleanupDoc();
} catch (_) {
// iframe document 可能已经导航或销毁,清理失败不影响关闭。
}
};
const startLoadTimer = () => {
if (disposed) return;
clearLoadTimer();
loadTimer = window.setTimeout(() => {
loadTimer = 0;
if (disposed || ticket !== this.job || !frame.isConnected || !status.isConnected) return;
showStatus('加载时间较长,可以重试或新标签打开。', statusActions());
}, Prefs.value.frameLoadWait * 1000);
};
const updateNavButtons = () => {
backButton.disabled = frameHistoryIndex <= 0;
forwardButton.disabled = frameHistoryIndex >= frameHistory.length - 1;
};
const saveFrameState = () => {
const start = Math.max(0, frameHistory.length - Prefs.value.frameHistoryLimit);
const history = frameHistory.slice(start);
const index = Math.max(0, frameHistoryIndex - start);
this.saveDrawerState({
mode: 'thread',
frameUrl: lastFrameUrl || frameHistory[frameHistoryIndex] || url,
frameHistory: history,
frameHistoryIndex: index
});
};
const formatHistoryUrl = (historyUrl) => {
try {
const parsed = new URL(historyUrl, location.origin);
return `${parsed.pathname}${parsed.search}${parsed.hash}` || parsed.href;
} catch (_) {
return historyUrl;
}
};
const hideHistoryMenu = () => {
if (historyHideTimer) {
window.clearTimeout(historyHideTimer);
historyHideTimer = 0;
}
historyMenu.classList.remove('is-open');
historyMenu.replaceChildren();
};
const scheduleHistoryHide = () => {
if (historyHideTimer) window.clearTimeout(historyHideTimer);
historyHideTimer = window.setTimeout(() => {
historyHideTimer = 0;
hideHistoryMenu();
}, 120);
};
const keepHistoryMenu = () => {
if (!historyHideTimer) return;
window.clearTimeout(historyHideTimer);
historyHideTimer = 0;
};
const positionHistoryMenu = (anchor, direction) => {
if (!frameShell?.isConnected || !anchor?.isConnected) return;
const shellRect = frameShell.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
const menuWidth = Math.min(340, Math.max(220, shellRect.width - 20));
const rawLeft = direction === 'forward'
? anchorRect.right - shellRect.left - menuWidth
: anchorRect.left - shellRect.left;
const maxLeft = Math.max(10, shellRect.width - menuWidth - 10);
const left = Math.max(10, Math.min(maxLeft, rawLeft));
const top = Math.max(42, anchorRect.bottom - shellRect.top + 6);
historyMenu.style.width = `${menuWidth}px`;
historyMenu.style.left = `${left}px`;
historyMenu.style.top = `${top}px`;
historyMenu.style.maxHeight = `${Math.max(120, shellRect.height - top - 10)}px`;
};
const showHistoryMenu = (direction, anchor = direction === 'forward' ? forwardButton : backButton) => {
const entries = direction === 'back'
? frameHistory.slice(0, frameHistoryIndex).map((href, index) => ({ href, index })).reverse()
: frameHistory.slice(frameHistoryIndex + 1).map((href, offset) => ({
href,
index: frameHistoryIndex + 1 + offset
}));
if (!entries.length) {
hideHistoryMenu();
return false;
}
keepHistoryMenu();
historyMenu.replaceChildren(...entries.map(({ href, index }) => Dom.make('button', {
className: `${NAME}-frame-history-item`,
type: 'button',
title: href,
'data-history-index': String(index),
text: formatHistoryUrl(href)
})));
historyMenu.classList.toggle('is-forward', direction === 'forward');
positionHistoryMenu(anchor, direction);
historyMenu.classList.add('is-open');
return true;
};
const pushFrameHistory = (nextUrl) => {
const normalizedUrl = Urls.framePreview(nextUrl);
if (!normalizedUrl || frameHistory[frameHistoryIndex] === normalizedUrl) {
updateNavButtons();
return;
}
frameHistory.splice(frameHistoryIndex + 1);
frameHistory.push(normalizedUrl);
frameHistoryIndex = frameHistory.length - 1;
while (frameHistory.length > Prefs.value.frameHistoryLimit) {
frameHistory.shift();
frameHistoryIndex = Math.max(0, frameHistoryIndex - 1);
}
hideHistoryMenu();
updateNavButtons();
saveFrameState();
};
const replaceFrameHistory = (nextUrl) => {
const normalizedUrl = Urls.framePreview(nextUrl);
if (!normalizedUrl) return false;
frameHistory[frameHistoryIndex] = normalizedUrl;
updateNavButtons();
saveFrameState();
return true;
};
const loadFrameUrl = (nextUrl, { pushHistory = true } = {}) => {
if (disposed) return false;
const normalizedUrl = Urls.framePreview(nextUrl);
if (!normalizedUrl) return false;
showStatus('正在加载详情');
startLoadTimer();
if (pushHistory) pushFrameHistory(normalizedUrl);
else replaceFrameHistory(normalizedUrl);
lastFrameUrl = normalizedUrl;
pendingFrameUrl = normalizedUrl;
pendingHistoryMode = 'replace';
pendingHistoryUntil = Date.now() + FRAME_HISTORY_REPLACE_WINDOW;
urlInput.value = normalizedUrl;
frame.src = normalizedUrl;
hideHistoryMenu();
updateNavButtons();
saveFrameState();
if (this.activeSidebarPanel === 'frameTools') this.renderSidebar();
return true;
};
const markFrameUserNavigation = () => {
pendingHistoryMode = '';
pendingHistoryUntil = 0;
frameUserNavigationUntil = Date.now() + FRAME_USER_NAV_WINDOW;
};
const watchFrameInteractions = () => {
try {
const doc = frame.contentDocument || frame.contentWindow?.document;
if (disposed) return;
if (!doc) {
releaseWatchedFrameDoc();
return;
}
if (watchedFrameDoc === doc) return;
releaseWatchedFrameDoc();
const mark = (event) => {
if (event?.isTrusted === false) return;
markFrameUserNavigation();
};
const markKeydown = (event) => {
if (event?.isTrusted === false) return;
if (event.key === 'Enter') {
markFrameUserNavigation();
return;
}
if (event.key !== ' ' && event.key !== 'Spacebar') return;
const target = event.target;
if (target?.closest?.('a, button, [role="button"], input[type="button"], input[type="submit"]')) {
markFrameUserNavigation();
}
};
doc.addEventListener('click', mark, true);
doc.addEventListener('auxclick', mark, true);
doc.addEventListener('submit', mark, true);
doc.addEventListener('keydown', markKeydown, true);
watchedFrameDoc = doc;
cleanupWatchedFrameDoc = () => {
doc.removeEventListener('click', mark, true);
doc.removeEventListener('auxclick', mark, true);
doc.removeEventListener('submit', mark, true);
doc.removeEventListener('keydown', markKeydown, true);
};
} catch (_) {
releaseWatchedFrameDoc();
// 跨源页面无法监听内部交互,地址栏仍可手动跳转。
}
};
const syncTitleFromFrame = () => {
try {
const doc = frame.contentDocument || frame.contentWindow?.document;
const nextTitle = Favorites.titleFromDocument(doc, topicId);
if (!nextTitle || nextTitle === this.topicTitle) return;
this.topicTitle = nextTitle;
ReadMemory.updateMeta(topicId, LastViewedMarker.anchor, this.sourceHref, nextTitle);
Favorites.updateMeta(topicId, LastViewedMarker.anchor, this.sourceHref, nextTitle);
this.saveDrawerState({
mode: 'thread',
frameUrl: currentFrameUrl(),
frameHistory,
frameHistoryIndex
});
FloatingPrefs.sync();
} catch (_) {
// 跨源或未完成加载时无法读取标题,保留已有名称。
}
};
const syncFrameUrl = (force = false) => {
const srcUrl = Urls.framePreview(frame.getAttribute('src') || '') || '';
let currentUrl = srcUrl;
try {
currentUrl = frame.contentWindow?.location?.href || currentUrl;
} catch (_) {
// 跨源 iframe 无法读取真实地址时,保留最后一次设置的 src。
}
currentUrl = Urls.framePreview(currentUrl) || currentUrl;
if (pendingHistoryMode && pendingHistoryUntil && Date.now() > pendingHistoryUntil) {
pendingHistoryMode = '';
pendingHistoryUntil = 0;
}
if (frameUserNavigationUntil && Date.now() > frameUserNavigationUntil) {
frameUserNavigationUntil = 0;
}
if (pendingFrameUrl && !force && srcUrl === pendingFrameUrl && currentUrl !== pendingFrameUrl) {
return;
}
const userNavigatingInFrame = !!frameUserNavigationUntil;
const shouldReplaceCurrentHistory = pendingHistoryMode === 'replace' || !userNavigatingInFrame;
if (pendingFrameUrl && (force || currentUrl === pendingFrameUrl)) {
pendingFrameUrl = '';
}
if (!currentUrl) return;
if (currentUrl === lastFrameUrl) return;
lastFrameUrl = currentUrl;
// 脚本主动加载后的规范化跳转只更新当前项,避免误删前进历史。
if (shouldReplaceCurrentHistory) {
replaceFrameHistory(currentUrl);
pendingHistoryMode = '';
pendingHistoryUntil = 0;
} else {
pushFrameHistory(currentUrl);
frameUserNavigationUntil = 0;
pendingHistoryMode = 'replace';
pendingHistoryUntil = Date.now() + FRAME_HISTORY_REPLACE_WINDOW;
}
if (force || document.activeElement !== urlInput) {
urlInput.value = currentUrl;
}
saveFrameState();
if (this.activeSidebarPanel === 'frameTools') this.renderSidebar();
};
const watchFrameUrl = () => {
frameWatchTimer = 0;
if (disposed || ticket !== this.job || !frame.isConnected || this.mode !== 'thread') return;
syncFrameUrl();
frameWatchTimer = window.setTimeout(watchFrameUrl, 500);
};
const navigate = (rawUrl) => {
const nextUrl = Urls.framePreview(rawUrl);
if (!nextUrl) {
showStatus('请输入 http(s) 地址或站内路径');
urlInput.focus();
return;
}
loadFrameUrl(nextUrl, { pushHistory: true });
};
const handleFrameAction = (action) => {
const currentUrl = currentFrameUrl();
if (action === 'jump') {
hideHistoryMenu();
navigate(urlInput.value);
return true;
}
if (action === 'reload' || action === 'retry') {
loadFrameUrl(currentUrl, { pushHistory: false });
return true;
}
if (action === 'copy') {
copyText(currentUrl, '已复制当前链接');
return true;
}
if (action === 'open') {
const opened = window.open(currentUrl, '_blank', 'noopener,noreferrer');
if (opened) opened.opener = null;
return true;
}
if (action === 'original') {
navigate(Urls.absolute(this.sourceHref || Urls.canonicalTopic(topicId)));
return true;
}
if (action === 'initial') {
navigate(initialTopicUrl);
return true;
}
return false;
};
this.frameControls = {
currentUrl: currentFrameUrl,
run: (action) => {
hideHistoryMenu();
return handleFrameAction(action);
}
};
this.loadedContentCleanup = () => {
if (disposed) return;
disposed = true;
clearLoadTimer();
clearFrameWatchTimer();
clearNudgeTimer();
hideHistoryMenu();
releaseWatchedFrameDoc();
try {
frame.src = 'about:blank';
frame.removeAttribute('src');
} catch (_) {
// iframe 释放失败时继续移除 DOM,避免关闭流程被打断。
}
};
this.renderSidebar();
frame.addEventListener('load', () => {
if (disposed || ticket !== this.job) return;
clearLoadTimer();
syncFrameUrl(true);
watchFrameInteractions();
this.bindAutoScrollFrame(frame);
syncTitleFromFrame();
status.remove();
clearNudgeTimer();
nudgeTimer = this.nudgeFrame(frame, ticket);
});
frame.addEventListener('error', () => {
if (disposed || ticket !== this.job) return;
clearLoadTimer();
showStatus('详情页面加载失败。', statusActions());
});
urlForm.addEventListener('submit', (event) => {
event.preventDefault();
hideHistoryMenu();
navigate(urlInput.value);
});
backButton.addEventListener('click', () => {
hideHistoryMenu();
if (frameHistoryIndex <= 0) return;
frameHistoryIndex -= 1;
loadFrameUrl(frameHistory[frameHistoryIndex], { pushHistory: false });
});
forwardButton.addEventListener('click', () => {
hideHistoryMenu();
if (frameHistoryIndex >= frameHistory.length - 1) return;
frameHistoryIndex += 1;
loadFrameUrl(frameHistory[frameHistoryIndex], { pushHistory: false });
});
backButton.addEventListener('mouseenter', () => showHistoryMenu('back'));
forwardButton.addEventListener('mouseenter', () => showHistoryMenu('forward'));
navGroup.addEventListener('mouseenter', keepHistoryMenu);
navGroup.addEventListener('mouseleave', scheduleHistoryHide);
historyMenu.addEventListener('mouseenter', keepHistoryMenu);
historyMenu.addEventListener('mouseleave', scheduleHistoryHide);
[backButton, forwardButton].forEach((button) => {
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
showHistoryMenu(button === backButton ? 'back' : 'forward', button);
});
});
historyMenu.addEventListener('click', (event) => {
const item = event.target.closest('[data-history-index]');
if (!item) return;
const index = Number(item.dataset.historyIndex);
if (!Number.isInteger(index) || index < 0 || index >= frameHistory.length) return;
frameHistoryIndex = index;
loadFrameUrl(frameHistory[frameHistoryIndex], { pushHistory: false });
});
status.addEventListener('click', (event) => {
const action = event.target.closest('[data-frame-action]')?.dataset.frameAction;
if (!action) return;
event.preventDefault();
handleFrameAction(action);
});
urlInput.addEventListener('blur', () => {
if (lastFrameUrl) urlInput.value = lastFrameUrl;
});
updateNavButtons();
frameShell = Dom.make('section', { className: `${NAME}-frame-shell` }, [
Dom.make('div', { className: `${NAME}-frame-top` }, [urlForm]),
frameWrap,
historyMenu
]);
frameShell.addEventListener('pointerdown', (event) => {
if (event.target.closest(`.${NAME}-frame-navs`)) return;
if (event.target.closest(`.${NAME}-frame-history-menu`)) return;
hideHistoryMenu();
});
this.body.replaceChildren(frameShell);
this.body.scrollTop = 0;
startLoadTimer();
saveFrameState();
watchFrameUrl();
},
threadFrameUrl(topicId) {
if (Prefs.value.threadSource === 'original' && this.sourceHref) {
return Urls.absolute(this.sourceHref);
}
return Urls.nestedThread(topicId);
},
nudgeFrame(frame, ticket) {
return window.setTimeout(() => {
if (ticket !== this.job || !frame.isConnected) return;
try {
const doc = frame.contentDocument || frame.contentWindow?.document;
const root = doc?.scrollingElement || doc?.documentElement;
if (!doc?.body || !root) return;
doc.documentElement.style.overscrollBehavior = 'contain';
doc.body.style.overscrollBehavior = 'contain';
const posts = Array.from(doc.querySelectorAll([
'article.topic-post',
'.topic-post',
'[data-post-id][data-post-number]',
'.topic-body'
].join(','))).filter((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 24;
});
const target = posts[1] || posts[0];
if (!target) return;
const top = Math.max(0, root.scrollTop + target.getBoundingClientRect().top - 12);
root.scrollTo({ top, behavior: 'smooth' });
} catch (_) {
// iframe 本身可用;这里仅做同源页面的滚动优化。
}
}, 160);
}
};
const MemoryStats = {
snapshot() {
const persistentParts = [
this.part('设置', Prefs.value, Object.keys(Prefs.value).length),
this.part('已读记忆', ReadMemory.items, ReadMemory.items.length),
this.part('收藏话题', Favorites.items, Favorites.items.length),
this.part('稍后阅读', ReadLaterQueue.items, ReadLaterQueue.items.length),
this.part('页面导航', PageNav.items, PageNav.items.length),
this.part('抽屉恢复', DrawerState.value, DrawerState.value ? 1 : 0)
];
const volatileParts = [
{ label: '预览缓存', count: StarterPostStore.cache.size, bytes: this.topicCacheBytes() },
this.part('预加载标记', Array.from(StarterPostStore.preloadAt.entries()), StarterPostStore.preloadAt.size),
this.part('相似话题索引', this.similarTopicPayload(), SimilarTopics.candidateCache?.length || 0),
this.part('话题标记索引', {
newTopicIds: Array.from(TopicBadges.newTopicIds),
watchingTopics: Array.from(TopicBadges.watchingTopics)
}, TopicBadges.newTopicIds.size + TopicBadges.watchingTopics.size),
this.part('当前抽屉', {
topicId: Drawer.topicId,
title: Drawer.topicTitle,
href: Drawer.sourceHref,
mode: Drawer.mode,
sidebarPanel: Drawer.activeSidebarPanel
}, Drawer.topicId ? 1 : 0)
];
const interfacePart = this.interfacePart();
const persistentBytes = this.sum(persistentParts);
const volatileBytes = this.sum(volatileParts);
const selfBytes = persistentBytes + volatileBytes + interfacePart.bytes;
return {
selfBytes,
persistentBytes,
volatileBytes,
interfacePart,
parts: [...persistentParts, ...volatileParts, interfacePart],
heap: this.pageHeap(),
sampledAt: Date.now()
};
},
part(label, value, count = 0) {
return {
label,
count: Number(count) || 0,
bytes: estimateSerializedBytes(value)
};
},
sum(parts) {
return parts.reduce((total, part) => total + (Number(part.bytes) || 0), 0);
},
topicCacheBytes() {
let bytes = 0;
StarterPostStore.cache.forEach((value, key) => {
bytes += estimateSerializedBytes(key);
const measured = StarterPostStore.cacheBytes.get(key);
bytes += Number.isFinite(measured) ? measured : estimateSerializedBytes(value);
});
return bytes;
},
similarTopicPayload() {
const index = [];
SimilarTopics.candidateIndex?.forEach((bucket, token) => {
index.push([token, bucket.length]);
});
return {
cacheKey: SimilarTopics.candidateCacheKey,
candidates: (SimilarTopics.candidateCache || []).map((candidate) => ({
id: candidate.item?.id,
normalized: candidate.normalized,
tokens: Array.from(candidate.tokens || [])
})),
index
};
},
interfacePart() {
const roots = [FloatingPrefs.root, Drawer.root, Drawer.shade, MiniEye.button].filter(Boolean);
const topRoots = roots.filter((node, index) => {
return !roots.some((other, otherIndex) => otherIndex !== index && other.contains?.(node));
});
let nodes = 0;
let textBytes = 0;
topRoots.forEach((root) => {
nodes += this.countInterfaceNodes(root);
textBytes += String(root.textContent || '').length * 2;
});
return {
label: '界面节点',
count: nodes,
bytes: nodes * 180 + textBytes
};
},
countInterfaceNodes(root) {
if (!(root instanceof Element)) return 0;
const showElement = window.NodeFilter?.SHOW_ELEMENT || 1;
const walker = document.createTreeWalker(root, showElement);
let count = 1;
while (walker.nextNode()) count += 1;
return count;
},
pageHeap() {
const memory = typeof performance !== 'undefined' ? performance.memory : null;
const used = Number(memory?.usedJSHeapSize);
const total = Number(memory?.totalJSHeapSize);
const limit = Number(memory?.jsHeapSizeLimit);
if (!Number.isFinite(used)) return { supported: false };
return {
supported: true,
used,
total: Number.isFinite(total) ? total : 0,
limit: Number.isFinite(limit) ? limit : 0
};
},
heapText(heap) {
if (!heap?.supported) return '浏览器不支持';
const total = heap.total ? ` / ${formatBytes(heap.total)}` : '';
const limit = heap.limit ? ` · 上限 ${formatBytes(heap.limit)}` : '';
return `${formatBytes(heap.used)}${total}${limit}`;
},
partText(part) {
const count = part.count ? ` · ${part.count}项` : '';
return `${formatBytes(part.bytes)}${count}`;
}
};
function formatFavoriteTime(timestamp) {
const time = Number(timestamp);
if (!Number.isFinite(time)) return '';
const diff = Date.now() - time;
if (diff < 60 * 1000) return '刚刚收藏';
if (diff < 60 * 60 * 1000) return `${Math.max(1, Math.floor(diff / 60000))}分钟前`;
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)}小时前`;
return new Date(time).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' });
}
function supportCards() {
return SUPPORT_LINKS.map((item) => Dom.make('a', {
className: `${NAME}-support-card`,
href: item.href,
target: '_blank',
rel: 'noopener noreferrer',
title: `${item.title}:${item.amount}`
}, [
Dom.make('span', { className: `${NAME}-support-card-copy` }, [
Dom.make('span', { className: `${NAME}-support-card-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-support-card-desc`, text: item.desc })
]),
Dom.make('span', { className: `${NAME}-support-card-side` }, [
Dom.make('span', { className: `${NAME}-support-card-amount`, text: item.amount }),
Dom.make('span', { className: `${NAME}-support-card-open`, text: '打开' })
])
]));
}
function supportHeaderButton() {
return Dom.make('button', {
type: 'button',
className: `${NAME}-support-chip`,
title: '支持我 / LDC 打赏',
'aria-label': '支持我 / LDC 打赏',
'aria-expanded': 'false',
'data-pref-action': 'toggle-support'
}, [
Dom.make('span', { className: `${NAME}-support-chip-icon`, 'aria-hidden': 'true', text: '♥' })
]);
}
function supportHeaderPanel() {
return Dom.make('div', {
className: `${NAME}-support-popover ${NAME}-support-list`,
hidden: 'hidden',
'data-pref-support-panel': '1'
}, supportCards());
}
function supportHeaderLink() {
return Dom.make('div', { className: `${NAME}-support-header` }, [
supportHeaderButton(),
supportHeaderPanel()
]);
}
const LiveTopics = {
panel: null,
button: null,
topics: [],
rawTopics: [],
categories: new Map(),
siteCategories: null,
siteCategoriesPromise: null,
seenIds: new Set(),
unreadIds: new Set(),
timer: 0,
inFlight: false,
lastFetchAt: 0,
error: '',
mount(button, host) {
this.button = button || this.button;
if (!this.panel) {
this.panel = this.buildPanel();
this.panel.addEventListener('click', (event) => this.onClick(event));
}
if (host && !this.panel.isConnected) host.appendChild(this.panel);
this.syncButton();
this.configure();
},
buildPanel() {
return Dom.make('section', {
className: `${NAME}-live-panel`,
'aria-label': '实时最新帖子'
});
},
toggle(force) {
const open = typeof force === 'boolean' ? force : !this.isOpen();
if (open) this.open();
else this.hide();
},
open() {
if (!this.panel) return;
FloatingPrefs.hide();
RecentTopics.hide();
this.panel.classList.add('is-open');
this.markAllRead();
this.render();
if (!this.topics.length && !this.inFlight) this.fetchNow({ manual: true });
},
hide() {
this.panel?.classList.remove('is-open');
this.syncButton();
},
isOpen() {
return this.panel?.classList.contains('is-open') === true;
},
configure() {
this.stop();
this.syncButton();
if (Prefs.value.liveEnabled) {
this.schedule();
if (!this.topics.length) this.fetchNow({ silent: true });
}
},
stop() {
if (!this.timer) return;
window.clearTimeout(this.timer);
this.timer = 0;
},
schedule() {
this.stop();
const delay = Math.max(3, Number(Prefs.value.livePollMinutes) || 10) * 60000;
this.timer = window.setTimeout(() => {
this.timer = 0;
if (Prefs.value.liveEnabled) this.fetchNow({ silent: true }).finally(() => this.schedule());
}, delay);
},
async fetchNow(options = {}) {
if (this.inFlight) return;
if (Prefs.value.livePauseHidden && document.visibilityState === 'hidden' && !options.manual) return;
this.inFlight = true;
this.error = '';
this.render();
Drawer.renderSidebar();
try {
this.applyPayload(await this.fetchLatestPayload());
this.lastFetchAt = Date.now();
if (options.manual) showToast('实时新帖已刷新');
} catch (error) {
this.error = error?.message || '刷新失败';
console.warn(`${LOG_PREFIX} 实时新帖刷新失败`, error);
if (options.manual) showToast('实时新帖刷新失败');
} finally {
this.inFlight = false;
this.syncButton();
this.render();
Drawer.renderSidebar();
}
},
async fetchLatestPayload() {
const payloads = [];
const seenTopicIds = new Set();
const targetCount = Math.max(5, Math.min(150, Number(Prefs.value.liveMaxTopics) || 50));
const pageLimit = Math.max(1, Math.min(6, Math.ceil(targetCount / 30) + 1));
for (let page = 0; page < pageLimit; page += 1) {
const res = await fetch(this.latestUrl(page), {
credentials: 'include',
headers: { Accept: 'application/json' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
const topics = Array.isArray(payload?.topic_list?.topics) ? payload.topic_list.topics : [];
if (page > 0 && !topics.length) break;
payloads.push(payload);
topics.forEach((topic) => {
const id = String(topic?.id || '');
if (id) seenTopicIds.add(id);
});
if (seenTopicIds.size >= targetCount) break;
}
return this.mergeLatestPayloads(payloads, await this.fetchSiteCategories());
},
latestUrl(page = 0) {
const params = new URLSearchParams();
if (Prefs.value.liveCreatedOrder) params.set('order', 'created');
if (page > 0) params.set('page', String(page));
const query = params.toString();
return `/latest.json${query ? `?${query}` : ''}`;
},
mergeLatestPayloads(payloads, siteCategories = []) {
const base = payloads[0] || {};
const topicMap = new Map();
const categories = Array.isArray(siteCategories) ? [...siteCategories] : [];
const categoryList = [];
const topicCategories = [];
payloads.forEach((payload) => {
(Array.isArray(payload?.topic_list?.topics) ? payload.topic_list.topics : []).forEach((topic) => {
const id = String(topic?.id || '');
if (id && !topicMap.has(id)) topicMap.set(id, topic);
});
if (Array.isArray(payload?.categories)) categories.push(...payload.categories);
if (Array.isArray(payload?.category_list?.categories)) categoryList.push(...payload.category_list.categories);
if (Array.isArray(payload?.topic_list?.categories)) topicCategories.push(...payload.topic_list.categories);
});
return {
...base,
categories,
category_list: {
...(base.category_list || {}),
categories: categoryList
},
topic_list: {
...(base.topic_list || {}),
categories: topicCategories,
topics: Array.from(topicMap.values())
}
};
},
async fetchSiteCategories() {
if (Array.isArray(this.siteCategories)) return this.siteCategories;
const cached = this.readSiteCategoriesCache();
if (cached.fresh) {
this.siteCategories = cached.categories;
return cached.categories;
}
if (!this.siteCategoriesPromise) {
this.siteCategoriesPromise = fetch('/site.json', {
credentials: 'include',
headers: { Accept: 'application/json' }
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}).then((payload) => {
const categories = this.flattenCategories(payload?.categories || payload?.category_list?.categories || []);
this.siteCategories = categories;
this.writeSiteCategoriesCache(categories);
return categories;
}).catch((error) => {
console.warn(`${LOG_PREFIX} 实时新帖分类表获取失败`, error);
this.siteCategories = cached.categories;
return cached.categories;
}).finally(() => {
this.siteCategoriesPromise = null;
});
}
return this.siteCategoriesPromise;
},
readSiteCategoriesCache() {
try {
const payload = JSON.parse(window.localStorage.getItem(SITE_CATEGORIES_KEY) || 'null');
const categories = Array.isArray(payload?.categories) ? payload.categories : [];
const fetchedAt = Number(payload?.fetchedAt || 0);
const fresh = categories.length > 0 && fetchedAt > 0 && Date.now() - fetchedAt < SITE_CATEGORIES_TTL;
return { categories, fresh };
} catch (_) {
return { categories: [], fresh: false };
}
},
writeSiteCategoriesCache(categories) {
try {
window.localStorage.setItem(SITE_CATEGORIES_KEY, JSON.stringify({
fetchedAt: Date.now(),
categories: Array.isArray(categories) ? categories : []
}));
} catch (_) {
// 分类表只是性能缓存,写入失败不影响实时列表功能。
}
},
siteCategoryChoices() {
const source = Array.isArray(this.siteCategories)
? this.siteCategories
: this.readSiteCategoriesCache().categories;
const map = new Map();
this.flattenCategories(source).forEach((category) => {
const id = Number(category?.id);
if (!Number.isFinite(id) || map.has(id)) return;
map.set(id, {
id,
name: category.name || category.title || `分类 ${id}`,
slug: category.slug || ''
});
});
return Array.from(map.values());
},
flattenCategories(list, output = []) {
if (!Array.isArray(list)) return output;
list.forEach((category) => {
if (!category || typeof category !== 'object') return;
output.push(category);
this.flattenCategories(category.subcategory_list, output);
this.flattenCategories(category.subcategories, output);
});
return output;
},
applyPayload(payload) {
this.categories = this.categoryMap(payload);
this.rawTopics = Array.isArray(payload?.topic_list?.topics) ? payload.topic_list.topics : [];
const nextTopics = this.filteredTopics();
if (this.seenIds.size) {
nextTopics.forEach((topic) => {
if (!this.seenIds.has(topic.id)) this.unreadIds.add(topic.id);
});
}
nextTopics.forEach((topic) => this.seenIds.add(topic.id));
this.topics = nextTopics;
if (this.isOpen()) this.markAllRead();
},
filteredTopics() {
return this.rawTopics
.map((topic) => this.topicModel(topic))
.filter(Boolean)
.filter((topic) => this.matchesFilter(topic))
.slice(0, Prefs.value.liveMaxTopics);
},
refreshFilter() {
this.topics = this.filteredTopics();
this.render();
Drawer.renderSidebar();
},
categoryMap(payload) {
const map = new Map();
[payload?.categories, payload?.category_list?.categories, payload?.topic_list?.categories].forEach((list) => {
this.flattenCategories(list).forEach((category) => {
this.addCategoryToMap(map, category);
});
});
return map;
},
addCategoryToMap(map, category) {
const id = Number(category?.id);
if (!Number.isFinite(id)) return;
map.set(id, {
id,
name: category.name || category.title || `分类 ${id}`,
slug: category.slug || ''
});
},
topicModel(topic) {
const id = String(topic?.id || '');
if (!id) return null;
const category = this.categories.get(Number(topic.category_id)) || null;
const slug = topic.slug ? `/${encodeURIComponent(topic.slug)}` : '';
return {
id,
title: topic.title || topic.fancy_title || `话题 ${id}`,
href: `/t${slug}/${encodeURIComponent(id)}`,
categoryId: Number(topic.category_id) || 0,
categoryName: category?.name || (topic.category_id ? `分类 ${topic.category_id}` : '未分类'),
categorySlug: category?.slug || '',
replies: Math.max(0, Number(topic.posts_count || 1) - 1),
views: Number(topic.views || 0),
lastPostedAt: topic.last_posted_at || topic.bumped_at || topic.created_at || ''
};
},
filterTokens() {
return String(Prefs.value.liveCategoryFilter || '')
.split(/[\n,,;;]+/)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
},
matchesFilter(topic) {
const tokens = this.filterTokens();
if (!tokens.length) return true;
const fields = [String(topic.categoryId || ''), topic.categoryName, topic.categorySlug]
.map((value) => String(value || '').toLowerCase());
return tokens.some((token) => fields.some((field) => field === token || field.includes(token)));
},
markAllRead() {
this.unreadIds.clear();
this.syncButton();
Drawer.renderSidebar();
},
syncButton() {
if (!this.button) return;
const count = this.unreadIds.size;
this.button.classList.toggle('is-active', this.isOpen());
this.button.classList.toggle('is-enabled', Prefs.value.liveEnabled);
this.button.setAttribute('aria-expanded', this.isOpen() ? 'true' : 'false');
this.button.setAttribute('title', Prefs.value.liveEnabled ? '实时新帖已开启' : '打开实时新帖');
const badge = this.button.querySelector('[data-live-count]');
if (badge) {
badge.textContent = count > 99 ? '99+' : String(count);
badge.toggleAttribute('hidden', count <= 0);
}
if (typeof FloatingPrefs !== 'undefined') {
FloatingPrefs.syncPieState?.();
}
},
render() {
if (!this.panel) return;
const rows = this.topics.length
? this.topics.map((topic) => this.topicRow(topic))
: [Dom.make('div', { className: `${NAME}-live-empty`, text: this.inFlight ? '正在刷新最新帖子…' : '暂无匹配的新帖' })];
this.panel.replaceChildren(
Dom.make('div', { className: `${NAME}-live-head` }, [
Dom.make('div', { className: `${NAME}-live-title`, text: '实时最新' }),
Dom.make('div', { className: `${NAME}-live-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-live-icon`, title: '刷新', 'aria-label': '刷新实时新帖', 'data-live-action': 'refresh', text: '↻' }),
Dom.make('button', { type: 'button', className: `${NAME}-live-icon`, title: '关闭', 'aria-label': '关闭实时新帖', 'data-live-action': 'close', text: '×' })
])
]),
Dom.make('div', { className: `${NAME}-live-status`, text: this.statusText() }),
Dom.make('div', { className: `${NAME}-live-list` }, rows)
);
},
topicRow(topic) {
const unread = this.unreadIds.has(topic.id);
return Dom.make('button', {
type: 'button',
className: `${NAME}-live-topic${unread ? ' is-unread' : ''}`,
title: topic.title,
'data-live-topic': topic.id
}, [
Dom.make('span', { className: `${NAME}-live-topic-title`, text: topic.title }),
Dom.make('span', { className: `${NAME}-live-topic-meta`, text: `${topic.categoryName} · ${this.relativeTime(topic.lastPostedAt)} · ${topic.replies} 回复 · ${topic.views} 浏览` })
]);
},
statusText() {
if (this.error) return `刷新失败:${this.error}`;
const mode = Prefs.value.liveEnabled ? `${Prefs.value.livePollMinutes} 分钟轮询` : '手动刷新';
const order = Prefs.value.liveCreatedOrder ? '创建排序' : '默认排序';
const filter = this.filterTokens().length ? '已过滤板块' : '全部板块';
const time = this.lastFetchAt ? new Date(this.lastFetchAt).toLocaleTimeString('zh-CN', { hour12: false }) : '尚未刷新';
return `${mode} · ${order} · ${filter} · ${time}`;
},
relativeTime(iso) {
const time = Date.parse(iso || '');
if (!Number.isFinite(time)) return '未知时间';
const minutes = Math.max(0, Math.round((Date.now() - time) / 60000));
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return `${Math.round(hours / 24)} 天前`;
},
onClick(event) {
const action = event.target.closest('[data-live-action]')?.dataset.liveAction;
if (action === 'refresh') {
event.preventDefault();
this.fetchNow({ manual: true });
return;
}
if (action === 'close') {
event.preventDefault();
this.hide();
return;
}
const topicId = event.target.closest('[data-live-topic]')?.dataset.liveTopic;
if (!topicId) return;
const topic = this.topics.find((item) => item.id === topicId);
if (!topic) return;
this.unreadIds.delete(topicId);
this.syncButton();
Drawer.open(topic.id, Prefs.value.mode, null, topic.href, {
title: topic.title,
sidebarPanel: 'live',
trackSource: 'live'
});
this.hide();
}
};
const RecentTopics = {
panel: null,
button: null,
mount(button, host) {
this.button = button || this.button;
if (!this.panel) {
this.panel = this.buildPanel();
this.panel.addEventListener('click', (event) => this.onClick(event));
}
if (host && !this.panel.isConnected) host.appendChild(this.panel);
this.syncButton();
},
buildPanel() {
return Dom.make('section', {
className: `${NAME}-live-panel ${NAME}-recent-panel`,
'aria-label': '最近查看'
});
},
toggle(force) {
const open = typeof force === 'boolean' ? force : !this.isOpen();
if (open) this.open();
else this.hide();
},
open() {
if (!this.panel) return;
FloatingPrefs.hide();
LiveTopics.hide();
this.panel.classList.add('is-open');
this.render();
this.syncButton();
},
hide() {
this.panel?.classList.remove('is-open');
this.syncButton();
},
isOpen() {
return this.panel?.classList.contains('is-open') === true;
},
items() {
return ReadMemory.items.slice(0, Prefs.value.recentLimit);
},
sync() {
this.syncButton();
if (this.isOpen()) this.render();
},
syncButton() {
if (!this.button) return;
const count = this.items().length;
this.button.classList.toggle('is-active', this.isOpen());
this.button.setAttribute('aria-expanded', this.isOpen() ? 'true' : 'false');
this.button.setAttribute('title', count ? `打开最近查看(${count})` : '打开最近查看');
const badge = this.button.querySelector('[data-recent-count]');
if (badge) {
badge.textContent = count > 99 ? '99+' : String(count);
badge.toggleAttribute('hidden', count <= 0);
}
},
render() {
if (!this.panel) return;
const items = this.items();
const rows = items.length
? items.map((item) => this.topicRow(item))
: [Dom.make('div', { className: `${NAME}-live-empty`, text: '暂无最近查看' })];
this.panel.replaceChildren(
Dom.make('div', { className: `${NAME}-live-head` }, [
Dom.make('div', { className: `${NAME}-live-title`, text: '最近查看' }),
Dom.make('div', { className: `${NAME}-live-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-live-icon`, title: '关闭', 'aria-label': '关闭最近查看', 'data-recent-action': 'close', text: '×' })
])
]),
Dom.make('div', { className: `${NAME}-live-status`, text: this.statusText(items.length) }),
Dom.make('div', { className: `${NAME}-live-list ${NAME}-recent-list` }, rows)
);
},
topicRow(item) {
const active = String(item.id) === String(Drawer.topicId || '');
return Dom.make('button', {
type: 'button',
className: `${NAME}-live-topic ${NAME}-recent-topic${active ? ' is-active' : ''}`,
title: `${item.title}\n${item.href || ''}`,
'data-recent-topic': item.id
}, [
Dom.make('span', { className: `${NAME}-live-topic-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-live-topic-meta`, text: formatFavoriteTime(item.readAt || item.at) })
]);
},
statusText(count) {
return `显示 ${count} / ${ReadMemory.items.length} 条 · 上限 ${Prefs.value.recentLimit}`;
},
onClick(event) {
const action = event.target.closest('[data-recent-action]')?.dataset.recentAction;
if (action === 'close') {
event.preventDefault();
this.hide();
return;
}
const topicId = event.target.closest('[data-recent-topic]')?.dataset.recentTopic;
if (!topicId) return;
const item = ReadMemory.get(topicId);
if (!item) return;
this.hide();
Drawer.open(item.id, Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'recent',
preserveRecentOrder: true,
trackSource: 'recent'
});
}
};
// 页面级设置入口,不依赖抽屉打开,方便用户先配置默认行为。
const FloatingPrefs = {
root: null,
button: null,
menu: null,
liveButton: null,
recentButton: null,
panel: null,
drag: null,
ignoreClick: false,
activeTab: 'quick',
keywordRefreshTimer: 0,
memoryTimer: 0,
recentSearch: '',
supportOpen: false,
pieHighlightSpan: 54,
mount() {
if (this.root) return;
this.button = Dom.make('button', {
className: `${NAME}-pref-button ${NAME}-pie-trigger`,
type: 'button',
title: `${APP_NAME} 快捷菜单`,
'aria-label': `${APP_NAME} 快捷菜单`,
'aria-expanded': 'false',
'data-pie-toggle': '1'
}, [
Dom.make('span', { className: `${NAME}-launcher-symbol`, 'aria-hidden': 'true' }, [
Dom.make('span', { className: `${NAME}-launcher-dot` }),
Dom.make('span', { className: `${NAME}-launcher-dot` }),
Dom.make('span', { className: `${NAME}-launcher-dot` }),
Dom.make('span', { className: `${NAME}-launcher-dot` })
])
]);
this.menu = this.buildPieMenu();
this.liveButton = this.menu.querySelector('[data-pie-action="live"]');
this.recentButton = this.menu.querySelector('[data-pie-action="recent"]');
this.panel = this.buildPanel();
this.root = Dom.make('div', { id: `${NAME}-prefs` }, [this.button, this.menu, this.panel]);
this.root.addEventListener('click', (event) => this.onClick(event));
this.root.addEventListener('input', (event) => this.onInput(event));
this.root.addEventListener('change', (event) => this.onInput(event));
this.menu.addEventListener('pointerover', (event) => this.onPieItemEnter(event));
this.menu.addEventListener('pointerout', (event) => this.onPieItemLeave(event));
this.menu.addEventListener('focusin', (event) => this.onPieItemEnter(event));
this.menu.addEventListener('focusout', () => this.onPieFocusOut());
this.button.addEventListener('pointerenter', (event) => this.onButtonPointerEnter(event));
this.button.addEventListener('pointerdown', (event) => this.onButtonPointerDown(event));
this.button.addEventListener('pointermove', (event) => this.onButtonPointerMove(event));
this.button.addEventListener('pointerup', (event) => this.onButtonPointerUp(event));
this.button.addEventListener('pointercancel', (event) => this.onButtonPointerUp(event));
this.panel.addEventListener('pointerdown', (event) => {
if (event.target.closest('[data-size-field], [data-size-input], [data-size-track]')) event.stopPropagation();
}, true);
this.bindSettingsRoot(this.panel);
document.addEventListener('pointerdown', (event) => {
if (!this.isOpen() && !this.isPieOpen() && !LiveTopics.isOpen() && !RecentTopics.isOpen()) return;
if (event.target instanceof Node && this.root.contains(event.target)) return;
this.hide();
LiveTopics.hide();
RecentTopics.hide();
}, true);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
this.hide();
LiveTopics.hide();
RecentTopics.hide();
}
});
document.body.appendChild(this.root);
LiveTopics.mount(this.liveButton, this.root);
RecentTopics.mount(this.recentButton, this.root);
this.applyTheme();
this.applyPosition();
this.sync();
},
pieItems() {
return [
{
id: 'drawer',
label: '抽屉',
title: '打开 / 继续抽屉',
icon: 'drawer'
},
{
id: 'live',
label: '实时',
title: '实时最新帖子',
icon: 'live',
badge: 'live'
},
{
id: 'recent',
label: '最近',
title: '最近查看',
icon: 'recent',
badge: 'recent'
},
{
id: 'queue',
label: '队列',
title: '稍后阅读队列',
icon: 'queue'
},
{
id: 'nav',
label: '导航',
title: '页面导航',
icon: 'nav'
},
{
id: 'topics',
label: '话题',
title: '收藏与最近查看',
icon: 'topics'
},
{
id: 'rules',
label: '筛选',
title: '关键词筛选规则',
icon: 'rules'
},
{
id: 'mode',
label: '模式',
title: '切换默认打开方式',
icon: 'mode'
},
{
id: 'data',
label: '数据',
title: '数据与缓存维护',
icon: 'data'
},
{
id: 'settings',
label: '设置',
title: `${APP_NAME} 设置`,
icon: 'settings'
}
];
},
buildPieMenu() {
return Dom.make('div', {
className: `${NAME}-pie-menu`,
role: 'menu',
'aria-label': `${APP_NAME} 快捷菜单`
}, this.pieItems().map((item, index, items) => this.pieItem(item, index, items.length)));
},
pieItem(item, index, total) {
const children = [
Dom.make('span', { className: `${NAME}-pie-item-icon ${NAME}-pie-icon-${item.icon}`, 'aria-hidden': 'true' }, this.pieIcon(item)),
Dom.make('span', { className: `${NAME}-pie-item-label`, text: item.label })
];
if (item.badge === 'live') {
children.push(Dom.make('span', {
className: `${NAME}-live-count`,
'data-live-count': '1',
hidden: 'hidden',
text: '0'
}));
}
if (item.badge === 'recent') {
children.push(Dom.make('span', {
className: `${NAME}-live-count ${NAME}-recent-count`,
'data-recent-count': '1',
hidden: 'hidden',
text: '0'
}));
}
return Dom.make('button', {
className: `${NAME}-pie-item`,
type: 'button',
role: 'menuitem',
title: item.title,
'aria-label': item.title,
'aria-expanded': 'false',
'data-pie-action': item.id,
'data-pie-index': String(index),
'data-pie-total': String(total)
}, children);
},
pieIcon(item) {
if (item.icon === 'drawer') {
return Dom.make('span', { className: `${NAME}-pie-drawer-icon` }, [
Dom.make('span')
]);
}
if (item.icon === 'settings') {
return Dom.make('span', { className: `${NAME}-pie-settings-icon` }, [
Dom.make('span'),
Dom.make('span'),
Dom.make('span')
]);
}
if (item.icon === 'live') {
return Dom.make('span', { className: `${NAME}-pie-live-icon` }, [
Dom.make('span'),
Dom.make('span')
]);
}
if (item.icon === 'recent') {
return Dom.make('span', { className: `${NAME}-pie-recent-icon` }, [
Dom.make('span'),
Dom.make('span')
]);
}
if (item.icon === 'queue') {
return Dom.make('span', { className: `${NAME}-pie-queue-icon` }, [
Dom.make('span'),
Dom.make('span'),
Dom.make('span')
]);
}
if (item.icon === 'topics') {
return Dom.make('span', { className: `${NAME}-pie-topics-icon` }, [
Dom.make('span')
]);
}
if (item.icon === 'nav') {
return Dom.make('span', { className: `${NAME}-pie-nav-icon` }, [
Dom.make('span')
]);
}
if (item.icon === 'rules') {
return Dom.make('span', { className: `${NAME}-pie-rules-icon` }, [
Dom.make('span')
]);
}
if (item.icon === 'mode') {
return Dom.make('span', { className: `${NAME}-pie-mode-icon` }, [
Dom.make('span')
]);
}
if (item.icon === 'data') {
return Dom.make('span', { className: `${NAME}-pie-data-icon` }, [
Dom.make('span')
]);
}
return Dom.make('span', { className: `${NAME}-pie-dot` });
},
onPieItemEnter(event) {
const item = event.target?.closest?.('[data-pie-action]');
if (!item || !this.menu?.contains(item)) return;
this.highlightPieItem(item);
},
onPieItemLeave(event) {
const item = event.target?.closest?.('[data-pie-action]');
if (!item || !this.menu?.contains(item)) return;
if (event.relatedTarget instanceof Node && item.contains(event.relatedTarget)) return;
const next = event.relatedTarget?.closest?.('[data-pie-action]');
if (next && this.menu.contains(next)) {
this.highlightPieItem(next);
return;
}
this.clearPieHighlight();
},
onPieFocusOut() {
window.requestAnimationFrame(() => {
const focused = document.activeElement?.closest?.('[data-pie-action]');
if (focused && this.menu?.contains(focused)) {
this.highlightPieItem(focused);
return;
}
this.clearPieHighlight();
});
},
highlightPieItem(item) {
if (!this.menu || !item || !this.menu.contains(item) || !this.isPieOpen()) return;
const angle = Number(item.dataset.pieAngle);
const span = Number(item.dataset.pieSpan) || this.pieHighlightSpan;
if (!Number.isFinite(angle)) return;
const label = item.querySelector(`.${NAME}-pie-item-label`)?.textContent || item.getAttribute('aria-label') || '';
const cssAngle = angle + 90;
this.menu.style.setProperty('--pie-highlight-start', `${Math.round(cssAngle - span / 2)}deg`);
this.menu.style.setProperty('--pie-highlight-span', `${Math.round(span)}deg`);
this.menu.setAttribute('data-pie-label', label);
this.menu.classList.add('is-highlighted');
},
restorePieHighlight() {
if (!this.menu || !this.isPieOpen()) {
this.clearPieHighlight();
return;
}
const focused = document.activeElement?.closest?.('[data-pie-action]');
if (focused && this.menu.contains(focused)) this.highlightPieItem(focused);
else this.clearPieHighlight();
},
clearPieHighlight() {
if (!this.menu) return;
this.menu.classList.remove('is-highlighted');
this.menu.removeAttribute('data-pie-label');
this.menu.style.removeProperty('--pie-highlight-start');
this.menu.style.removeProperty('--pie-highlight-span');
},
buildPanel() {
return Dom.make('div', {
className: `${NAME}-settings ${NAME}-floating-settings`,
role: 'menu',
'aria-label': `${APP_NAME} 设置`
}, this.buildSettingsContent());
},
buildDockSettingsPanel() {
return Dom.make('section', {
className: `${NAME}-sidebar-panel ${NAME}-sidebar-settings-panel`,
'aria-label': '设置'
}, [
Dom.make('div', {
className: `${NAME}-settings ${NAME}-dock-settings`,
'data-dock-settings': '1'
}, this.buildSettingsContent())
]);
},
buildSettingsContent() {
return [
Dom.make('div', { className: `${NAME}-prefs-head` }, [
Dom.make('div', { className: `${NAME}-prefs-title-row` }, [
Dom.make('div', { className: `${NAME}-prefs-title`, text: `${APP_NAME} 设置` }),
supportHeaderLink()
]),
Dom.make('div', { className: `${NAME}-prefs-subtitle`, 'data-role': 'prefs-current' })
]),
Dom.make('div', { className: `${NAME}-pref-tab-strip`, 'aria-label': `${APP_NAME} 设置分组导航` }, [
this.tabScrollButton(-1),
Dom.make('div', {
className: `${NAME}-pref-tabs`,
role: 'tablist',
'aria-label': `${APP_NAME} 设置分组`,
'data-pref-tab-track': '1'
}, [
this.tabButton('quick', '队列'),
this.tabButton('nav', '导航'),
this.tabButton('display', '阅读'),
this.tabButton('rules', '筛选'),
this.tabButton('live', '实时'),
this.tabButton('topics', '话题'),
this.tabButton('data', '数据')
]),
this.tabScrollButton(1)
]),
this.tabPanel('quick', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '抽屉入口' }),
Dom.make('button', {
type: 'button',
className: `${NAME}-resume-button`,
'data-pref-action': 'open-drawer'
}, [
Dom.make('span', { className: `${NAME}-favorite-title`, 'data-role': 'drawer-open-label', text: '打开抽屉' }),
Dom.make('span', { className: `${NAME}-favorite-meta`, 'data-role': 'drawer-open-meta', text: '暂无可继续阅读内容' })
])
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title` }, [
Dom.make('span', { text: '稍后阅读' }),
Dom.make('span', { className: `${NAME}-section-count`, 'data-count-role': 'queue', text: '0' })
]),
Dom.make('div', { className: `${NAME}-queue-keep-hint`, text: READ_LATER_KEEP_HINT }),
Dom.make('div', { className: `${NAME}-favorite-list`, 'data-role': 'queue' }),
Dom.make('div', { className: `${NAME}-management-actions ${NAME}-queue-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'clear-queue', text: '清空队列' })
])
])
]),
this.tabPanel('nav', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title` }, [
Dom.make('span', { text: '页面导航' }),
Dom.make('span', { className: `${NAME}-section-count`, 'data-count-role': 'page-nav', text: '0' })
]),
Dom.make('div', { className: `${NAME}-favorite-list ${NAME}-page-nav-list`, 'data-role': 'page-nav' }),
Dom.make('div', { className: `${NAME}-management-actions ${NAME}-page-nav-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'add-page-nav', text: '添加当前' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'restore-page-nav', text: '恢复默认' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'clear-page-nav', text: '清空导航' })
])
])
]),
this.tabPanel('display', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '站点偏好' }),
this.prefToggleButton('forceCreatedOrder', '按创建排序', '访问 LinuxDO 页面时自动使用 order=created'),
this.prefToggleButton('clickTopicToDrawer', '单击话题进抽屉', '开启后拦截普通左键单击;关闭时正常跳转'),
this.prefToggleButton('trackDrawerViews', '抽屉计入后台', '开启后抽屉停留片刻会补发 LinuxDo 后台话题浏览统计')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '后台统计入口' }),
this.prefToggleButton('trackDrawerViewsLive', '实时列表', '从实时新帖列表打开抽屉时计入后台'),
this.prefToggleButton('trackDrawerViewsTopicLinks', '话题快捷入口', '从页面话题快捷按钮或单击话题进抽屉时计入后台'),
this.prefToggleButton('trackDrawerViewsReadLater', '稍后阅读', '从稍后阅读队列打开抽屉时计入后台'),
this.prefToggleButton('trackDrawerViewsRecent', '最近查看', '从最近查看打开抽屉时计入后台'),
this.prefToggleButton('trackDrawerViewsResume', '继续抽屉', '从快捷菜单继续或恢复抽屉时计入后台'),
this.prefToggleButton('trackDrawerViewsFavorites', '收藏话题', '从收藏话题打开抽屉时计入后台')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '自动滚动速度' }),
this.autoScrollSpeedButton('fast', AUTO_SCROLL_SPEED_LEVELS.fast.label, AUTO_SCROLL_SPEED_LEVELS.fast.help),
this.autoScrollSpeedButton('normal', AUTO_SCROLL_SPEED_LEVELS.normal.label, AUTO_SCROLL_SPEED_LEVELS.normal.help),
this.autoScrollSpeedButton('slow', AUTO_SCROLL_SPEED_LEVELS.slow.label, AUTO_SCROLL_SPEED_LEVELS.slow.help)
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '默认打开方式' }),
this.settingButton('summary', '预览模式', '读取话题 JSON,展示楼主正文'),
this.settingButton('thread', '详情模式', '右侧抽屉中加载详情页面 iframe')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '详情链接来源' }),
this.threadSourceButton('nested', THREAD_SOURCES.nested.label, THREAD_SOURCES.nested.help),
this.threadSourceButton('original', THREAD_SOURCES.original.label, THREAD_SOURCES.original.help)
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '抽屉分辨率' }),
Dom.make('div', { className: `${NAME}-size-hint`, text: '点击进度条、加减按钮或数字框调整宽度和高度。' }),
this.sizeControl('width', '宽度'),
this.sizeControl('height', '高度')
])
]),
this.tabPanel('rules', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '关键词规则' }),
this.keywordBox('keywordBlockList', '屏蔽关键词', '命中的话题会在列表里隐藏。'),
this.keywordBox('keywordHighlightList', '高亮关键词', '命中的标题会用文字和徽标标记。')
])
]),
this.tabPanel('live', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '实时新帖' }),
this.prefToggleButton('liveEnabled', '启用自动轮询', '开启后按设定间隔检查 /latest.json'),
this.prefToggleButton('livePauseHidden', '后台暂停轮询', '页面不可见时跳过自动刷新'),
this.prefToggleButton('liveCreatedOrder', '按创建排序', '请求 /latest.json?order=created'),
Dom.make('div', { className: `${NAME}-management-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'open-live', text: '打开实时窗' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'refresh-live', text: '立即刷新' })
])
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '轮询与数量' }),
this.tuningControl('livePollMinutes'),
this.tuningControl('liveMaxTopics')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '板块过滤' }),
this.keywordBox('liveCategoryFilter', '只显示这些板块', '可点选下方板块,也可手写 slug、名称或 ID;留空显示全部。'),
this.liveCategoryPicker()
])
]),
this.tabPanel('topics', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title` }, [
Dom.make('span', { text: '收藏话题' }),
Dom.make('span', { className: `${NAME}-section-count`, 'data-count-role': 'favorites', text: '0' })
]),
Dom.make('div', { className: `${NAME}-favorite-list`, 'data-role': 'favorites' })
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title` }, [
Dom.make('span', { text: '最近查看' }),
Dom.make('span', { className: `${NAME}-section-count`, 'data-count-role': 'recent', text: '0' })
]),
Dom.make('input', {
className: `${NAME}-list-search`,
type: 'search',
placeholder: '搜索标题、链接或备注',
autocomplete: 'off',
spellcheck: 'false',
'data-recent-search': '1',
value: this.recentSearch
}),
Dom.make('div', { className: `${NAME}-favorite-list`, 'data-role': 'recent' })
])
]),
this.tabPanel('data', [
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '内存监控' }),
Dom.make('div', { className: `${NAME}-memory-grid` }, [
this.memoryRow('self-total', 'LDPeek 估算'),
this.memoryRow('page-heap', '当前页 JS堆'),
this.memoryRow('persistent-data', '持久数据'),
this.memoryRow('volatile-data', '运行缓存'),
this.memoryRow('interface-nodes', '界面节点'),
this.memoryRow('sampled-at', '采样时间')
]),
Dom.make('div', { className: `${NAME}-memory-parts`, 'data-memory-role': 'parts' }),
Dom.make('div', { className: `${NAME}-management-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'refresh-memory', text: '刷新内存' })
])
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '页面标记' }),
this.prefToggleButton('showReadBadges', '显示已读标记', '在话题标题后显示脚本已预览'),
this.prefToggleButton('showEffectiveBadges', '显示话题已生效', 'new-topic 小蓝点消失后显示'),
this.prefToggleButton('showCategoryColors', '显示分类颜色', '在话题行左侧显示分类颜色')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '保存数量' }),
this.tuningControl('memoryLimit'),
this.tuningControl('favoriteLimit'),
this.tuningControl('recentLimit'),
this.tuningControl('pageNavLimit')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '缓存与历史' }),
this.tuningControl('topicCacheLimit'),
this.tuningControl('frameHistoryLimit')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '等待时间' }),
this.tuningControl('previewPrefetchDelay'),
this.tuningControl('previewCacheTtl'),
this.tuningControl('badgeRefreshDelay'),
this.tuningControl('frameLoadWait')
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '一键清理策略' }),
Dom.make('div', { className: `${NAME}-management-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'cleanup-old-read', text: '清理7天前已读' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'cleanup-cache', text: '清理无效缓存' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'cleanup-keep-favorites', text: '保留收藏清空其他' })
])
]),
Dom.make('div', { className: `${NAME}-setting-group` }, [
Dom.make('div', { className: `${NAME}-setting-group-title`, text: '配置管理' }),
Dom.make('div', { className: `${NAME}-management-actions` }, [
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'clear-read', text: '清空已读' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'export-data', text: '导出配置' }),
Dom.make('button', { type: 'button', className: `${NAME}-soft-btn`, 'data-pref-action': 'import-data', text: '导入配置' })
])
])
])
];
},
bindSettingsRoot(root) {
root?.querySelector('[data-pref-tab-track]')?.addEventListener('scroll', () => {
this.syncTabScrollControls(root);
}, { passive: true });
},
dockSettingsRoot() {
return Drawer.sidebar?.querySelector(`.${NAME}-dock-settings`) || null;
},
settingsRoots() {
return [this.panel, this.dockSettingsRoot()].filter((root) => root?.isConnected);
},
forEachSettingsRoot(callback) {
this.settingsRoots().forEach((root) => callback(root));
},
isDockSettingsOpen() {
return Drawer.activeSidebarPanel === 'settings' && !!this.dockSettingsRoot();
},
tabScrollButton(direction) {
const isLeft = Number(direction) < 0;
const path = isLeft ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6';
return Dom.make('button', {
type: 'button',
className: `${NAME}-pref-tab-scroll ${isLeft ? 'is-left' : 'is-right'}`,
title: isLeft ? '向左查看设置分组' : '向右查看设置分组',
'aria-label': isLeft ? '向左查看设置分组' : '向右查看设置分组',
'data-pref-tab-scroll': String(direction),
html: `<svg class="${NAME}-pref-tab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${path}"></path></svg>`
});
},
tabButton(tab, label) {
const countRole = tab === 'quick'
? 'queue-tab'
: tab === 'topics'
? 'topics'
: tab === 'nav'
? 'nav-tab'
: '';
return Dom.make('button', {
type: 'button',
className: `${NAME}-pref-tab`,
role: 'tab',
'aria-selected': 'false',
'data-pref-tab': tab
}, [
Dom.make('span', { className: `${NAME}-pref-tab-label`, text: label }),
countRole
? Dom.make('span', {
className: `${NAME}-pref-tab-count`,
'data-count-role': countRole,
text: '0'
})
: null
]);
},
tabPanel(tab, children) {
return Dom.make('div', {
className: `${NAME}-pref-tab-panel`,
role: 'tabpanel',
'data-pref-panel': tab
}, children);
},
settingButton(mode, label, desc) {
return Dom.make('button', { type: 'button', className: `${NAME}-setting`, 'data-default-mode': mode }, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
threadSourceButton(source, label, desc) {
return Dom.make('button', { type: 'button', className: `${NAME}-setting`, 'data-thread-source': source }, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
autoScrollSpeedButton(speed, label, desc) {
return Dom.make('button', { type: 'button', className: `${NAME}-setting`, 'data-auto-scroll-speed': speed }, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
prefToggleButton(field, label, desc) {
return Dom.make('button', { type: 'button', className: `${NAME}-setting`, 'data-pref-toggle': field }, [
Dom.make('span', { className: `${NAME}-setting-copy` }, [
Dom.make('span', { className: `${NAME}-setting-label`, text: label }),
Dom.make('span', { className: `${NAME}-setting-desc`, text: desc })
]),
Dom.make('span', { className: `${NAME}-setting-dot`, 'aria-hidden': 'true' })
]);
},
sizeControl(field, label) {
return SizeControls.build(field, label);
},
tuningControl(field) {
return TuningControls.build(field);
},
memoryRow(role, label) {
return Dom.make('div', { className: `${NAME}-memory-row` }, [
Dom.make('span', { className: `${NAME}-memory-label`, text: label }),
Dom.make('span', { className: `${NAME}-memory-value`, 'data-memory-role': role, text: '等待采样' })
]);
},
keywordBox(field, label, desc) {
return Dom.make('label', { className: `${NAME}-keyword-field` }, [
Dom.make('span', { className: `${NAME}-keyword-label`, text: label }),
Dom.make('span', { className: `${NAME}-keyword-desc`, text: desc }),
Dom.make('textarea', {
className: `${NAME}-keyword-input`,
rows: '3',
spellcheck: 'false',
placeholder: '每行一个,或用逗号分隔',
'data-keyword-input': field,
text: Prefs.value[field]
})
]);
},
liveCategoryPicker() {
return Dom.make('div', { className: `${NAME}-category-picker`, 'data-live-category-picker': '1' }, this.liveCategoryPickerContent());
},
liveCategoryPickerContent() {
const categories = LiveTopics.siteCategoryChoices();
if (!categories.length) {
return Dom.make('div', { className: `${NAME}-category-picker-empty`, text: '打开或刷新实时新帖后显示可选板块。' });
}
const tokens = new Set(LiveTopics.filterTokens());
return categories.map((category) => {
const token = category.slug || category.name || String(category.id);
const values = [String(category.id), category.slug, category.name].map((value) => String(value || '').toLowerCase()).filter(Boolean);
const active = values.some((value) => tokens.has(value));
return Dom.make('button', {
type: 'button',
className: `${NAME}-category-chip${active ? ' is-active' : ''}`,
title: `${active ? '移除' : '添加'}板块过滤:${category.name}${category.slug ? ` / ${category.slug}` : ''}`,
'aria-pressed': active ? 'true' : 'false',
'data-live-category-token': token
}, [
Dom.make('span', { className: `${NAME}-category-chip-name`, text: category.name || `分类 ${category.id}` }),
category.slug ? Dom.make('span', { className: `${NAME}-category-chip-slug`, text: category.slug }) : null
]);
});
},
onClick(event) {
if (this.ignoreClick) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.target.closest('[data-pie-toggle]')) {
event.preventDefault();
this.togglePie();
return;
}
const pieAction = event.target.closest('[data-pie-action]')?.dataset.pieAction;
if (pieAction === 'drawer') {
event.preventDefault();
this.hidePie();
const opened = this.openDrawerTarget();
if (!opened) this.collapseEdgeStateIfIdle();
if (!opened) showToast('暂无可打开的抽屉内容');
return;
}
if (pieAction === 'settings') {
event.preventDefault();
this.openSettingsPanel('display');
return;
}
if (pieAction === 'live') {
event.preventDefault();
this.hidePie();
LiveTopics.toggle();
this.collapseEdgeStateIfIdle();
return;
}
if (pieAction === 'recent') {
event.preventDefault();
this.hidePie();
RecentTopics.toggle();
this.collapseEdgeStateIfIdle();
return;
}
if (pieAction === 'queue') {
event.preventDefault();
this.openSettingsPanel('quick');
return;
}
if (pieAction === 'nav') {
event.preventDefault();
this.openSettingsPanel('nav');
return;
}
if (pieAction === 'topics') {
event.preventDefault();
this.openSettingsPanel('topics');
return;
}
if (pieAction === 'rules') {
event.preventDefault();
this.openSettingsPanel('rules');
return;
}
if (pieAction === 'mode') {
event.preventDefault();
this.hidePie();
const nextMode = Prefs.value.mode === 'summary' ? 'thread' : 'summary';
Prefs.save({ mode: nextMode });
Drawer.syncControls();
this.sync();
this.collapseEdgeStateIfIdle();
showToast(`默认打开方式:${MODES[nextMode]?.label || nextMode}`);
return;
}
if (pieAction === 'data') {
event.preventDefault();
this.openSettingsPanel('data');
return;
}
if (event.target.closest('[data-pref-action="toggle-support"]')) {
this.toggleSupport();
return;
}
const tabScrollButton = event.target.closest('[data-pref-tab-scroll]');
const tabScroll = tabScrollButton?.dataset.prefTabScroll;
if (tabScroll) {
this.scrollTabs(Number(tabScroll), tabScrollButton.closest(`.${NAME}-settings`));
return;
}
const nextTab = event.target.closest('[data-pref-tab]')?.dataset.prefTab;
if (nextTab) {
this.activeTab = nextTab;
this.syncTabs();
if (nextTab === 'quick') this.renderQueue();
if (nextTab === 'live') this.ensureLiveCategoryChoices();
return;
}
const liveCategoryToken = event.target.closest('[data-live-category-token]')?.dataset.liveCategoryToken;
if (liveCategoryToken) {
event.preventDefault();
this.toggleLiveCategoryToken(liveCategoryToken);
return;
}
const prefToggle = event.target.closest('[data-pref-toggle]')?.dataset.prefToggle;
if (prefToggle === 'showReadBadges' || prefToggle === 'showEffectiveBadges' || prefToggle === 'showCategoryColors' || prefToggle === 'forceCreatedOrder' || prefToggle === 'clickTopicToDrawer' || prefToggle === 'trackDrawerViews' || TRACK_DRAWER_VIEW_PREFS.includes(prefToggle) || prefToggle === 'liveEnabled' || prefToggle === 'livePauseHidden' || prefToggle === 'liveCreatedOrder') {
Prefs.save({ [prefToggle]: !Prefs.value[prefToggle] });
if (prefToggle === 'forceCreatedOrder' && Prefs.value.forceCreatedOrder) {
if (CreatedOrder.apply()) return;
}
if (prefToggle === 'showReadBadges' || prefToggle === 'showEffectiveBadges' || prefToggle === 'showCategoryColors') {
TopicBadges.refresh();
}
if (prefToggle === 'trackDrawerViews' || TRACK_DRAWER_VIEW_PREFS.includes(prefToggle)) {
Drawer.scheduleTrackView();
}
if (prefToggle === 'liveEnabled' || prefToggle === 'livePauseHidden') LiveTopics.configure();
if (prefToggle === 'liveCreatedOrder') LiveTopics.fetchNow({ manual: true });
Drawer.syncControls();
this.sync();
return;
}
const prefAction = event.target.closest('[data-pref-action]')?.dataset.prefAction;
if (prefAction === 'open-drawer') {
const opened = this.openDrawerTarget();
if (!opened) showToast('暂无可打开的抽屉内容');
return;
}
if (prefAction === 'clear-read') {
if (window.confirm('确定清空已读记忆吗?')) {
ReadMemory.clear();
Drawer.syncControls();
this.sync();
showToast('已清空已读记忆');
}
return;
}
if (prefAction === 'clear-queue') {
if (ReadLaterQueue.items.length && window.confirm('确定清空稍后阅读队列吗?')) {
if (!ReadLaterQueue.clear()) return;
this.syncQueueState();
Drawer.syncReadLaterState();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
showToast('已清空稍后阅读队列');
}
return;
}
if (prefAction === 'clear-page-nav') {
if (PageNav.items.length && window.confirm('确定清空页面导航吗?')) {
PageNav.clear();
this.sync();
Drawer.renderSidebar();
showToast('已清空页面导航');
}
return;
}
if (prefAction === 'add-page-nav') {
const result = PageNav.addCurrent();
this.sync();
Drawer.renderSidebar();
showToast(!result?.item ? '当前页面无法添加' : result.existing ? '当前页面已在导航中' : '已添加当前页面');
return;
}
if (prefAction === 'restore-page-nav') {
PageNav.restoreDefaults();
this.sync();
Drawer.renderSidebar();
showToast('已恢复默认导航');
return;
}
if (prefAction === 'cleanup-old-read') {
const removed = ReadMemory.clearOlderThan(7);
this.syncAfterCleanup();
showToast(removed ? `已清理 ${removed} 条7天前已读` : '没有7天前已读');
return;
}
if (prefAction === 'cleanup-cache') {
const cleared = StarterPostStore.clearVolatileCache();
this.syncMemoryStats();
showToast(cleared ? `已清理 ${cleared} 条缓存` : '暂无可清理缓存');
return;
}
if (prefAction === 'cleanup-keep-favorites') {
if (!window.confirm('保留收藏和设置,清空已读、页面导航、稍后阅读、抽屉恢复状态和缓存吗?')) return;
const readCount = ReadMemory.items.length;
const queueCount = ReadLaterQueue.items.length;
const navCount = PageNav.items.length;
const cacheCount = StarterPostStore.clearVolatileCache();
ReadMemory.clear();
ReadLaterQueue.clear();
PageNav.clear();
DrawerState.clear();
LastViewedMarker.clear({ keepState: false });
this.syncAfterCleanup();
showToast(`已清理 ${readCount + queueCount + navCount + cacheCount} 条记录`);
return;
}
if (prefAction === 'export-data') {
exportLocalData();
return;
}
if (prefAction === 'import-data') {
importLocalData();
this.sync();
Drawer.syncControls();
return;
}
if (prefAction === 'refresh-memory') {
this.syncMemoryStats();
showToast('内存数据已刷新');
return;
}
if (prefAction === 'open-live') {
LiveTopics.open();
return;
}
if (prefAction === 'refresh-live') {
LiveTopics.fetchNow({ manual: true });
return;
}
const defaultMode = event.target.closest('[data-default-mode]')?.dataset.defaultMode;
if (defaultMode) {
Prefs.save({ mode: defaultMode });
Drawer.syncControls();
this.sync();
return;
}
const threadSource = event.target.closest('[data-thread-source]')?.dataset.threadSource;
if (threadSource) {
Prefs.save({ threadSource });
Drawer.syncControls();
this.sync();
if (Drawer.topicId && Drawer.mode === 'thread') {
Drawer.open(Drawer.topicId, Drawer.mode, LastViewedMarker.anchor, Drawer.sourceHref, { trackSource: Drawer.trackViewSource });
}
return;
}
const autoScrollSpeed = event.target.closest('[data-auto-scroll-speed]')?.dataset.autoScrollSpeed;
if (autoScrollSpeed) {
Prefs.save({ autoScrollSpeed });
Drawer.applyAutoScrollSpeedPreference();
Drawer.syncControls();
this.sync();
showToast(`自动滚动速度:${AUTO_SCROLL_SPEED_LEVELS[Prefs.value.autoScrollSpeed].label}`);
return;
}
const sizeStep = event.target.closest('[data-size-step]');
if (sizeStep) {
const next = SizeControls.fromStep(sizeStep);
if (next) this.persistSizeValue(next.field, next.value);
return;
}
const tuningStep = event.target.closest('[data-tuning-step]');
if (tuningStep) {
const next = TuningControls.fromStep(tuningStep);
if (next) this.persistTuningValue(next.field, next.value);
return;
}
const sizeTrack = event.target.closest('[data-size-track]');
if (sizeTrack) {
const next = SizeControls.fromTrack(sizeTrack, event.clientX);
if (next) this.persistSizeValue(next.field, next.value);
return;
}
const removeQueue = event.target.closest('[data-queue-remove]')?.dataset.queueRemove;
if (removeQueue) {
if (!ReadLaterQueue.remove(removeQueue)) return;
this.syncQueueState();
Drawer.syncReadLaterState();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
showToast('已移出稍后阅读');
return;
}
const openQueue = event.target.closest('[data-queue-open]')?.dataset.queueOpen;
if (openQueue) {
const item = ReadLaterQueue.get(openQueue);
if (!item) return;
this.hide();
this.sync();
Drawer.renderSidebar();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
Drawer.open(item.id, Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'readLater',
trackSource: 'readLater'
});
return;
}
const openPageNav = event.target.closest('[data-page-nav-open]')?.dataset.pageNavOpen;
if (openPageNav) {
const item = PageNav.get(openPageNav);
if (!item) return;
if (!PageNav.isCurrent(item)) this.hide();
PageNav.open(openPageNav);
return;
}
const blankPageNav = event.target.closest('[data-page-nav-blank]')?.dataset.pageNavBlank;
if (blankPageNav) {
PageNav.open(blankPageNav, { newTab: true });
return;
}
const editPageNav = event.target.closest('[data-page-nav-edit]')?.dataset.pageNavEdit;
if (editPageNav) {
const result = PageNav.editWithPrompt(editPageNav);
if (result.cancelled) return;
showToast(PageNav.editResultMessage(result));
return;
}
const removePageNav = event.target.closest('[data-page-nav-remove]')?.dataset.pageNavRemove;
if (removePageNav) {
const next = PageNav.isCurrent(removePageNav) ? PageNav.neighbor(removePageNav) : null;
if (!PageNav.remove(removePageNav)) return;
this.sync();
Drawer.renderSidebar();
showToast('已关闭导航标签');
if (next) window.location.assign(next.href);
return;
}
const removeFavorite = event.target.closest('[data-favorite-remove]')?.dataset.favoriteRemove;
if (removeFavorite) {
Favorites.remove(removeFavorite);
Drawer.syncControls();
this.sync();
showToast('已移除收藏');
return;
}
const openFavorite = event.target.closest('[data-favorite-open]')?.dataset.favoriteOpen;
if (openFavorite) {
const item = Favorites.get(openFavorite);
if (!item) return;
this.hide();
Drawer.open(item.id, Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'favorites',
trackSource: 'favorites'
});
return;
}
const openRecent = event.target.closest('[data-recent-open]')?.dataset.recentOpen;
if (openRecent) {
const item = ReadMemory.get(openRecent);
if (!item) return;
this.hide();
Drawer.open(item.id, Prefs.value.mode, null, item.href, {
title: item.title,
sidebarPanel: 'recent',
preserveRecentOrder: true,
trackSource: 'recent'
});
return;
}
},
onButtonPointerEnter(event) {
if (event.pointerType === 'touch' || this.drag || !this.isEdgeHidden()) return;
this.togglePie(true);
},
onButtonPointerDown(event) {
if (event.button !== 0 || !this.root || !this.button) return;
const rect = this.root.getBoundingClientRect();
this.drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
left: rect.left,
top: rect.top,
moved: false,
active: false,
pendingMove: false,
edgeHidden: this.isEdgeHidden(),
timer: window.setTimeout(() => {
if (!this.drag || this.drag.pointerId !== event.pointerId) return;
this.activateDrag();
const deltaX = this.drag.currentX - this.drag.startX;
const deltaY = this.drag.currentY - this.drag.startY;
if (Math.hypot(deltaX, deltaY) >= 3) {
this.drag.moved = true;
this.applyPosition(this.drag.left + deltaX, this.drag.top + deltaY);
}
}, FLOATING_LONG_PRESS)
};
this.button.setPointerCapture?.(event.pointerId);
},
onButtonPointerMove(event) {
if (!this.drag || this.drag.pointerId !== event.pointerId) return;
this.drag.currentX = event.clientX;
this.drag.currentY = event.clientY;
let deltaX = event.clientX - this.drag.startX;
let deltaY = event.clientY - this.drag.startY;
let distance = Math.hypot(deltaX, deltaY);
if (!this.drag.active) {
if (distance > 8) {
if (this.drag.timer) {
window.clearTimeout(this.drag.timer);
this.drag.timer = 0;
}
this.drag.pendingMove = true;
this.activateDrag();
deltaX = event.clientX - this.drag.startX;
deltaY = event.clientY - this.drag.startY;
distance = Math.hypot(deltaX, deltaY);
} else {
return;
}
}
if (!this.drag.moved && distance < 3) return;
this.drag.moved = true;
this.applyPosition(this.drag.left + deltaX, this.drag.top + deltaY);
event.preventDefault();
},
onButtonPointerUp(event) {
if (!this.drag || this.drag.pointerId !== event.pointerId) return;
if (this.drag.timer) {
window.clearTimeout(this.drag.timer);
this.drag.timer = 0;
}
const wasActive = this.drag.active;
const pendingMove = this.drag.pendingMove;
const drag = this.drag;
this.button.releasePointerCapture?.(event.pointerId);
this.root?.classList.remove('is-dragging');
this.drag = null;
if (!wasActive) {
if (pendingMove) {
this.ignoreClick = true;
window.setTimeout(() => {
this.ignoreClick = false;
}, 0);
}
return;
}
const position = this.clampPosition(
drag.left + drag.currentX - drag.startX,
drag.top + drag.currentY - drag.startY
);
const edgeState = this.edgeStateFromPosition(position);
if (edgeState) {
Prefs.save({ prefEdgeState: edgeState });
this.applyEdgeState(edgeState);
} else {
Prefs.save({
prefLeft: position.left,
prefTop: position.top,
prefEdgeState: null
});
this.applyPosition(position.left, position.top);
}
this.ignoreClick = true;
window.setTimeout(() => {
this.ignoreClick = false;
}, 0);
},
activateDrag() {
if (!this.drag || !this.root) return;
if (this.drag.edgeHidden) {
this.revealEdgeState();
const rect = this.root.getBoundingClientRect();
this.drag.left = rect.left;
this.drag.top = rect.top;
this.drag.startX = this.drag.currentX;
this.drag.startY = this.drag.currentY;
this.drag.edgeHidden = false;
}
this.drag.active = true;
this.root.classList.add('is-dragging');
this.hide();
LiveTopics.hide();
},
onInput(event) {
const sizeInput = event.target.closest('[data-size-input]');
if (sizeInput) {
const next = event.type === 'input'
? SizeControls.fromTyping(sizeInput)
: SizeControls.fromInput(sizeInput);
if (next) this.persistSizeValue(next.field, next.value);
return;
}
const tuningInput = event.target.closest('[data-tuning-input]');
if (tuningInput) {
const next = event.type === 'input'
? TuningControls.fromTyping(tuningInput)
: TuningControls.fromInput(tuningInput);
if (next) this.persistTuningValue(next.field, next.value);
return;
}
const keywordInput = event.target.closest('[data-keyword-input]');
if (keywordInput) {
this.persistKeywordValue(keywordInput.dataset.keywordInput, keywordInput.value, {
immediate: event.type === 'change'
});
return;
}
const recentSearch = event.target.closest('[data-recent-search]');
if (!recentSearch) return;
this.recentSearch = recentSearch.value;
this.renderRecent();
this.syncTopicTabCount();
},
syncTopicTabCount() {
this.forEachSettingsRoot((root) => {
const recentCount = root.querySelector('[data-count-role="recent"]');
if (recentCount) recentCount.textContent = String(this.recentItems().length);
const topicCount = root.querySelector('[data-count-role="topics"]');
if (topicCount) topicCount.textContent = String(Favorites.items.length + this.recentItems().length);
});
},
drawerTarget() {
if (Drawer.topicId) {
return {
label: '回到当前抽屉',
topicId: Drawer.topicId,
title: Drawer.topicTitle || `话题 ${Drawer.topicId}`,
href: Drawer.sourceHref,
mode: Drawer.mode || Prefs.value.mode,
trackSource: 'resume'
};
}
const currentTopicId = Urls.topicIdFromHref(location.href);
if (currentTopicId) {
return {
label: '打开当前话题',
topicId: currentTopicId,
title: Favorites.titleFromDocument(document, currentTopicId),
href: location.href,
mode: Prefs.value.mode,
trackSource: 'resume'
};
}
if (DrawerState.value?.topicId) {
const mode = Prefs.value.mode;
return {
label: '继续上次抽屉',
topicId: DrawerState.value.topicId,
title: DrawerState.value.title,
href: DrawerState.value.sourceHref,
mode,
resumeState: mode === 'thread' ? DrawerState.value : null,
trackSource: 'resume'
};
}
const queued = ReadLaterQueue.items[0];
if (queued) {
return {
label: '打开稍后阅读',
topicId: queued.id,
title: queued.title,
href: queued.href,
mode: Prefs.value.mode,
sidebarPanel: 'readLater',
trackSource: 'readLater'
};
}
const recent = ReadMemory.items[0];
if (recent) {
return {
label: '打开最近查看',
topicId: recent.id,
title: recent.title,
href: recent.href,
mode: Prefs.value.mode,
trackSource: 'recent'
};
}
const favorite = Favorites.items[0];
if (favorite) {
return {
label: '打开收藏话题',
topicId: favorite.id,
title: favorite.title,
href: favorite.href,
mode: Prefs.value.mode,
trackSource: 'favorites'
};
}
return null;
},
openDrawerTarget() {
const target = this.drawerTarget();
if (!target?.topicId) return false;
this.hide();
if (Drawer.topicId === String(target.topicId) && Drawer.root?.classList.contains('is-open')) {
return true;
}
Drawer.open(target.topicId, target.mode || Prefs.value.mode, null, target.href, {
title: target.title,
resumeState: target.resumeState,
sidebarPanel: target.sidebarPanel,
trackSource: target.trackSource
});
return true;
},
persistSizeValue(field, value) {
Prefs.save({ [field]: SizeControls.clamp(field, value) });
Drawer.applySize();
Drawer.syncControls();
this.sync();
},
persistTuningValue(field, value) {
const nextValue = TuningControls.clamp(field, value);
if (nextValue === null) return;
Prefs.save({ [field]: nextValue });
this.applyTuningSideEffects(field);
Drawer.syncControls();
this.sync();
},
applyTuningSideEffects(field) {
if (field === 'memoryLimit') {
SimilarTopics.invalidate();
if (!ReadMemory.trimToLimit()) TopicBadges.refresh();
}
if (field === 'favoriteLimit') Favorites.trimToLimit();
if (field === 'pageNavLimit') PageNav.trimToLimit();
if (field === 'topicCacheLimit') {
StarterPostStore.trimCache();
StarterPostStore.trimPreloadMarks();
}
if (field === 'frameHistoryLimit' && DrawerState.value?.frameHistory?.length) {
DrawerState.save({ frameHistory: DrawerState.value.frameHistory, frameHistoryIndex: DrawerState.value.frameHistoryIndex });
}
if (field === 'badgeRefreshDelay') TopicBadges.schedule();
if (field === 'livePollMinutes' || field === 'liveMaxTopics') {
LiveTopics.configure();
LiveTopics.refreshFilter();
}
},
syncAfterCleanup() {
TopicBadges.refresh();
Drawer.syncControls();
Drawer.renderSidebar();
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
this.sync();
},
persistKeywordValue(field, value, options = {}) {
if (field !== 'keywordBlockList' && field !== 'keywordHighlightList' && field !== 'liveCategoryFilter') return;
Prefs.save({ [field]: Prefs.cleanTextSetting(value) });
if (field === 'liveCategoryFilter') {
LiveTopics.refreshFilter();
this.renderLiveCategoryPicker();
return;
}
this.scheduleKeywordRefresh(options.immediate === true);
this.syncKeywordInputs();
},
toggleLiveCategoryToken(token) {
const value = String(token || '').trim();
if (!value) return;
const current = String(Prefs.value.liveCategoryFilter || '')
.split(/[\n,,;;]+/)
.map((item) => item.trim())
.filter(Boolean);
const index = current.findIndex((item) => item.toLowerCase() === value.toLowerCase());
if (index >= 0) current.splice(index, 1);
else current.push(value);
Prefs.save({ liveCategoryFilter: current.join('\n') });
LiveTopics.refreshFilter();
this.syncKeywordInputs();
this.renderLiveCategoryPicker();
},
renderLiveCategoryPicker() {
this.forEachSettingsRoot((root) => {
const picker = root.querySelector('[data-live-category-picker]');
if (!picker) return;
const content = this.liveCategoryPickerContent();
picker.replaceChildren(...(Array.isArray(content) ? content : [content]));
});
},
ensureLiveCategoryChoices() {
const choices = LiveTopics.siteCategoryChoices();
const cache = LiveTopics.readSiteCategoriesCache();
if (choices.length) {
this.renderLiveCategoryPicker();
if (cache.fresh || Array.isArray(LiveTopics.siteCategories)) return;
}
LiveTopics.fetchSiteCategories().then(() => {
if (this.activeTab === 'live') this.renderLiveCategoryPicker();
});
},
scheduleKeywordRefresh(immediate = false) {
if (this.keywordRefreshTimer) {
window.clearTimeout(this.keywordRefreshTimer);
this.keywordRefreshTimer = 0;
}
if (immediate) {
KeywordRules.refresh();
return;
}
this.keywordRefreshTimer = window.setTimeout(() => {
this.keywordRefreshTimer = 0;
KeywordRules.refresh();
}, 220);
},
toggle() {
const open = !this.isOpen();
if (open) {
this.openSettingsPanel();
return;
}
this.hidePie();
this.panel.classList.remove('is-open');
this.applyTheme();
this.applyPanelPlacement();
this.syncPieState();
this.sync();
this.syncMemoryMonitor();
},
openSettingsPanel(tab = '') {
if (tab) this.activeTab = tab;
this.revealEdgeState();
LiveTopics.hide();
RecentTopics.hide();
this.hidePie();
this.panel.classList.add('is-open');
this.applyTheme();
this.applyPanelPlacement();
this.syncPieState();
this.sync();
this.syncMemoryMonitor();
if (this.activeTab === 'live') this.ensureLiveCategoryChoices();
},
hide() {
if (!this.panel) return;
this.panel.classList.remove('is-open');
this.hidePie();
this.toggleSupport(false);
this.syncPieState();
this.stopMemoryMonitor();
this.collapseEdgeStateIfIdle();
},
isOpen() {
return this.panel?.classList.contains('is-open') === true;
},
togglePie(force) {
if (!this.root) return;
const open = typeof force === 'boolean' ? force : !this.isPieOpen();
if (open) {
this.revealEdgeState();
this.panel?.classList.remove('is-open');
LiveTopics.hide();
RecentTopics.hide();
this.toggleSupport(false);
this.stopMemoryMonitor();
this.resetPieViewportOffset();
this.root.classList.add('is-pie-open');
this.syncPieViewportOffset();
this.syncPieLayout();
} else {
this.root.classList.remove('is-pie-open');
this.resetPieViewportOffset();
this.collapseEdgeStateIfIdle();
}
this.syncPieState();
},
hidePie() {
if (!this.root) return;
this.root.classList.remove('is-pie-open');
this.resetPieViewportOffset();
if (document.activeElement instanceof HTMLElement && this.menu?.contains(document.activeElement)) {
document.activeElement.blur();
}
this.syncPieState();
},
isPieOpen() {
return this.root?.classList.contains('is-pie-open') === true;
},
syncPieState() {
if (!this.root) return;
const pieOpen = this.isPieOpen();
this.button?.setAttribute('aria-expanded', pieOpen ? 'true' : 'false');
this.menu?.querySelectorAll('[data-pie-action]').forEach((button) => {
const action = button.dataset.pieAction;
const active = action === 'settings' ? this.isOpen() && (this.activeTab === 'display' || this.activeTab === 'live') :
action === 'live' ? LiveTopics.isOpen() :
action === 'recent' ? RecentTopics.isOpen() :
action === 'queue' ? this.isOpen() && this.activeTab === 'quick' :
action === 'nav' ? this.isOpen() && this.activeTab === 'nav' :
action === 'topics' ? this.isOpen() && this.activeTab === 'topics' :
action === 'rules' ? this.isOpen() && this.activeTab === 'rules' :
action === 'data' ? this.isOpen() && this.activeTab === 'data' :
action === 'mode' ? Prefs.value.mode === 'thread' : false;
button.classList.toggle('is-active', active);
button.setAttribute('aria-expanded', active ? 'true' : 'false');
});
this.restorePieHighlight();
},
sync() {
if (!this.root) return;
this.forEachSettingsRoot((root) => {
root.querySelectorAll('[data-default-mode]').forEach((button) => {
const active = button.dataset.defaultMode === Prefs.value.mode;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
root.querySelectorAll('[data-thread-source]').forEach((button) => {
const active = button.dataset.threadSource === Prefs.value.threadSource;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
root.querySelectorAll('[data-auto-scroll-speed]').forEach((button) => {
const active = button.dataset.autoScrollSpeed === Prefs.value.autoScrollSpeed;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
root.querySelectorAll('[data-pref-toggle]').forEach((button) => {
const active = Prefs.value[button.dataset.prefToggle] !== false;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
SizeControls.sync(root);
TuningControls.sync(root);
const current = root.querySelector('[data-role="prefs-current"]');
if (current) {
const mode = MODES[Prefs.value.mode]?.label || '预览';
const source = THREAD_SOURCES[Prefs.value.threadSource]?.label || '楼层视图';
const autoScroll = AUTO_SCROLL_SPEED_LEVELS[Prefs.value.autoScrollSpeed]?.label || AUTO_SCROLL_SPEED_LEVELS[AUTO_SCROLL_DEFAULT_SPEED_LEVEL].label;
current.textContent = `默认 ${mode} · ${source} · 滚动 ${autoScroll} · ${Prefs.value.width}px × ${Prefs.value.height}vh`;
}
const favoriteCount = root.querySelector('[data-count-role="favorites"]');
if (favoriteCount) favoriteCount.textContent = String(Favorites.items.length);
const queueCount = root.querySelector('[data-count-role="queue"]');
if (queueCount) queueCount.textContent = String(ReadLaterQueue.items.length);
const queueTabCount = root.querySelector('[data-count-role="queue-tab"]');
if (queueTabCount) queueTabCount.textContent = String(ReadLaterQueue.items.length);
const pageNavCount = root.querySelector('[data-count-role="page-nav"]');
if (pageNavCount) pageNavCount.textContent = String(PageNav.items.length);
const navTabCount = root.querySelector('[data-count-role="nav-tab"]');
if (navTabCount) navTabCount.textContent = String(PageNav.items.length);
const recentCount = root.querySelector('[data-count-role="recent"]');
if (recentCount) recentCount.textContent = String(this.recentItems().length);
const topicCount = root.querySelector('[data-count-role="topics"]');
if (topicCount) {
topicCount.textContent = String(Favorites.items.length + this.recentItems().length);
}
});
this.syncKeywordInputs();
this.syncDrawerEntry();
this.syncSupportPanel();
this.syncPieState();
LiveTopics.syncButton();
RecentTopics.sync();
this.syncTabs();
this.renderQueue();
this.renderPageNav();
this.renderFavorites();
this.renderRecent();
if ((this.isOpen() || this.isDockSettingsOpen()) && this.activeTab === 'data') this.syncMemoryStats();
},
syncQueueState() {
if (!this.root) return;
const count = String(ReadLaterQueue.items.length);
this.forEachSettingsRoot((root) => {
const queueCount = root.querySelector('[data-count-role="queue"]');
if (queueCount) queueCount.textContent = count;
const queueTabCount = root.querySelector('[data-count-role="queue-tab"]');
if (queueTabCount) queueTabCount.textContent = count;
});
this.syncDrawerEntry();
if ((this.isOpen() || this.isDockSettingsOpen()) && this.activeTab === 'quick') this.renderQueue();
},
syncMemoryStats() {
if (!this.root) return;
const snapshot = MemoryStats.snapshot();
this.setMemoryText('self-total', formatBytes(snapshot.selfBytes));
this.setMemoryText('page-heap', MemoryStats.heapText(snapshot.heap));
this.setMemoryText('persistent-data', formatBytes(snapshot.persistentBytes));
this.setMemoryText('volatile-data', formatBytes(snapshot.volatileBytes));
this.setMemoryText('interface-nodes', `${formatBytes(snapshot.interfacePart.bytes)} · ${snapshot.interfacePart.count}节点`);
this.setMemoryText('sampled-at', new Date(snapshot.sampledAt).toLocaleTimeString('zh-CN', { hour12: false }));
this.forEachSettingsRoot((root) => {
const parts = root.querySelector('[data-memory-role="parts"]');
if (!parts) return;
parts.replaceChildren(...snapshot.parts
.filter((part) => part.bytes || part.count)
.map((part) => Dom.make('div', { className: `${NAME}-memory-part` }, [
Dom.make('span', { className: `${NAME}-memory-part-label`, text: part.label }),
Dom.make('span', { className: `${NAME}-memory-part-value`, text: MemoryStats.partText(part) })
])));
});
},
setMemoryText(role, text) {
this.forEachSettingsRoot((root) => {
const node = root.querySelector(`[data-memory-role="${role}"]`);
if (!node) return;
node.textContent = text;
node.title = text;
});
},
toggleSupport(force) {
this.supportOpen = typeof force === 'boolean' ? force : !this.supportOpen;
this.syncSupportPanel();
},
syncSupportPanel() {
if (!this.root) return;
this.forEachSettingsRoot((root) => {
const button = root.querySelector('[data-pref-action="toggle-support"]');
const panel = root.querySelector('[data-pref-support-panel]');
if (button) button.setAttribute('aria-expanded', this.supportOpen ? 'true' : 'false');
if (panel) panel.toggleAttribute('hidden', !this.supportOpen);
});
},
syncMemoryMonitor() {
if ((this.isOpen() || this.isDockSettingsOpen()) && this.activeTab === 'data') {
this.startMemoryMonitor();
} else {
this.stopMemoryMonitor();
}
},
startMemoryMonitor() {
this.syncMemoryStats();
if (this.memoryTimer) return;
this.memoryTimer = window.setInterval(() => this.syncMemoryStats(), 2000);
},
stopMemoryMonitor() {
if (!this.memoryTimer) return;
window.clearInterval(this.memoryTimer);
this.memoryTimer = 0;
},
syncKeywordInputs() {
if (!this.root) return;
this.forEachSettingsRoot((root) => {
root.querySelectorAll('[data-keyword-input]').forEach((input) => {
const field = input.dataset.keywordInput;
if (document.activeElement === input) return;
if (field === 'keywordBlockList' || field === 'keywordHighlightList' || field === 'liveCategoryFilter') {
input.value = Prefs.value[field];
}
});
});
},
syncDrawerEntry() {
const target = this.drawerTarget();
this.forEachSettingsRoot((root) => {
const button = root.querySelector('[data-pref-action="open-drawer"]');
if (!button) return;
const label = button.querySelector('[data-role="drawer-open-label"]');
const meta = button.querySelector('[data-role="drawer-open-meta"]');
button.disabled = !target;
button.toggleAttribute('disabled', !target);
button.setAttribute('aria-disabled', target ? 'false' : 'true');
button.setAttribute('title', target ? `${target.label}:${target.title}` : '暂无可打开的抽屉内容');
if (label) label.textContent = target?.label || '打开抽屉';
if (meta) meta.textContent = target?.title || '暂无可继续阅读内容';
});
},
syncTabs() {
if (!this.root) return;
const validTabs = new Set(['quick', 'nav', 'display', 'rules', 'live', 'topics', 'data']);
if (!validTabs.has(this.activeTab)) this.activeTab = 'quick';
this.forEachSettingsRoot((root) => {
let activeButton = null;
root.querySelectorAll('[data-pref-tab]').forEach((button) => {
const active = button.dataset.prefTab === this.activeTab;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
if (active) activeButton = button;
});
root.querySelectorAll('[data-pref-panel]').forEach((panel) => {
const active = panel.dataset.prefPanel === this.activeTab;
panel.classList.toggle('is-active', active);
panel.toggleAttribute('hidden', !active);
});
this.revealActiveTab(activeButton);
this.syncTabScrollControls(root);
});
this.syncMemoryMonitor();
},
scrollTabs(direction, root = this.panel) {
const track = root?.querySelector('[data-pref-tab-track]');
if (!track) return;
const amount = Math.max(80, Math.round(track.clientWidth * .72));
track.scrollBy({
left: Number(direction) < 0 ? -amount : amount,
behavior: 'smooth'
});
window.setTimeout(() => this.syncTabScrollControls(root), 220);
},
revealActiveTab(button) {
if (!button) return;
try {
button.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} catch (_) {
// 旧浏览器不支持对象参数时,保持当前滚动位置即可。
}
},
syncTabScrollControls(root = this.panel) {
const track = root?.querySelector('[data-pref-tab-track]');
if (!track) return;
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
const leftButton = root.querySelector('[data-pref-tab-scroll="-1"]');
const rightButton = root.querySelector('[data-pref-tab-scroll="1"]');
const disabled = maxScroll <= 1;
const canScrollLeft = !disabled && track.scrollLeft > 1;
const canScrollRight = !disabled && track.scrollLeft < maxScroll - 1;
if (leftButton) leftButton.disabled = !canScrollLeft;
if (rightButton) rightButton.disabled = !canScrollRight;
const strip = track.closest(`.${NAME}-pref-tab-strip`);
if (strip) {
strip.classList.toggle('is-scroll-left', canScrollLeft);
strip.classList.toggle('is-scroll-right', canScrollRight);
}
},
renderQueue() {
this.forEachSettingsRoot((root) => {
const list = root.querySelector('[data-role="queue"]');
if (!list) return;
if (!ReadLaterQueue.items.length) {
list.replaceChildren(Dom.make('div', { className: `${NAME}-favorite-empty`, text: '暂无稍后阅读' }));
return;
}
list.replaceChildren(...ReadLaterQueue.items.map((item, index) => Dom.make('div', { className: `${NAME}-favorite-row ${NAME}-queue-row` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-item ${NAME}-queue-item`,
title: item.title,
'data-queue-open': item.id
}, [
Dom.make('span', { className: `${NAME}-favorite-title`, text: `${index + 1}. ${item.title}` }),
Dom.make('span', { className: `${NAME}-favorite-meta`, text: `加入队列 ${formatFavoriteTime(item.at)}` })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-remove`,
title: '移出稍后阅读',
'aria-label': `移出稍后阅读:${item.title}`,
'data-queue-remove': item.id,
text: '×'
})
])));
});
},
renderPageNav() {
this.forEachSettingsRoot((root) => {
const list = root.querySelector('[data-role="page-nav"]');
if (!list) return;
if (!PageNav.items.length) {
list.replaceChildren(Dom.make('div', { className: `${NAME}-favorite-empty`, text: '暂无页面导航' }));
return;
}
list.replaceChildren(...PageNav.items.map((item, index) => {
const active = PageNav.isCurrent(item);
const displayHref = PageNav.displayHref(item.href);
return Dom.make('div', { className: `${NAME}-favorite-row ${NAME}-page-nav-row${active ? ' is-active' : ''}` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-item ${NAME}-page-nav-item`,
title: `${item.title} · ${displayHref}`,
'aria-current': active ? 'page' : undefined,
'data-page-nav-open': item.id
}, [
Dom.make('span', { className: `${NAME}-favorite-title`, text: `${index + 1}. ${item.title}` }),
Dom.make('span', { className: `${NAME}-favorite-meta`, text: active ? `当前页面 · ${displayHref}` : displayHref })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-page-nav-action`,
title: '编辑导航标题和地址',
'aria-label': `编辑导航标题和地址:${item.title}`,
'data-page-nav-edit': item.id,
text: '编'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-page-nav-action`,
title: '新标签打开',
'aria-label': `新标签打开:${item.title}`,
'data-page-nav-blank': item.id,
text: '↗'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-remove`,
title: '关闭导航标签',
'aria-label': `关闭导航标签:${item.title}`,
'data-page-nav-remove': item.id,
text: '×'
})
]);
}));
});
},
renderFavorites() {
this.forEachSettingsRoot((root) => {
const list = root.querySelector('[data-role="favorites"]');
if (!list) return;
if (!Favorites.items.length) {
list.replaceChildren(Dom.make('div', { className: `${NAME}-favorite-empty`, text: '暂无收藏话题' }));
return;
}
list.replaceChildren(...Favorites.items.map((item) => Dom.make('div', { className: `${NAME}-favorite-row` }, [
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-item`,
title: item.title,
'data-favorite-open': item.id
}, [
Dom.make('span', { className: `${NAME}-favorite-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-favorite-meta`, text: formatFavoriteTime(item.at) })
]),
Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-remove`,
title: '移除收藏',
'aria-label': `移除收藏:${item.title}`,
'data-favorite-remove': item.id,
text: '×'
})
])));
});
},
renderRecent() {
const items = this.recentItems();
this.forEachSettingsRoot((root) => {
const list = root.querySelector('[data-role="recent"]');
if (!list) return;
if (!items.length) {
const emptyText = ReadMemory.items.length && this.recentSearch.trim()
? '没有匹配的最近查看'
: '暂无最近查看';
list.replaceChildren(Dom.make('div', { className: `${NAME}-favorite-empty`, text: emptyText }));
return;
}
list.replaceChildren(...items.map((item) => Dom.make('button', {
type: 'button',
className: `${NAME}-favorite-item ${NAME}-recent-item`,
title: `${item.title}\n${item.href || ''}`,
'data-recent-open': item.id
}, [
Dom.make('span', { className: `${NAME}-favorite-title`, text: item.title }),
Dom.make('span', { className: `${NAME}-favorite-meta`, text: formatFavoriteTime(item.readAt || item.at) })
])));
});
},
recentItems() {
const query = this.recentSearch.trim().toLowerCase();
const source = query
? ReadMemory.items.filter((item) => this.matchesRecentSearch(item, query))
: ReadMemory.items;
return source.slice(0, Prefs.value.recentLimit);
},
matchesRecentSearch(item, query) {
const note = item?.note || item?.remark || item?.memo || '';
return [
item?.title,
item?.href,
note
].some((value) => String(value || '').toLowerCase().includes(query));
},
applyTheme() {
if (!this.root) return;
const dark = pagePrefersDarkTheme();
this.root.classList.toggle('is-light', !dark);
},
applyPosition(left, top) {
if (!this.root) return;
if (arguments.length === 0) {
if (Prefs.value.prefEdgeState) {
this.applyEdgeState(Prefs.value.prefEdgeState, { hidden: !this.isInteractionOpen() });
return;
}
left = Prefs.value.prefLeft;
top = Prefs.value.prefTop;
}
if (!Number.isFinite(left) || !Number.isFinite(top)) {
this.root.style.removeProperty('left');
this.root.style.removeProperty('top');
this.root.style.removeProperty('right');
this.root.style.removeProperty('bottom');
this.root.classList.remove('is-positioned');
this.clearEdgeClasses();
this.applyPanelPlacement();
if (this.isPieOpen()) this.syncPieViewportOffset();
this.syncPieLayout();
return;
}
const position = this.clampPosition(left, top);
this.clearEdgeClasses();
this.root.style.left = `${position.left}px`;
this.root.style.top = `${position.top}px`;
this.root.style.right = 'auto';
this.root.style.bottom = 'auto';
this.root.classList.add('is-positioned');
this.applyPanelPlacement(position.left, position.top);
if (this.isPieOpen()) this.syncPieViewportOffset();
},
clampPosition(left, top) {
const width = this.button?.offsetWidth || FLOATING_BUTTON_SIZE;
const height = this.button?.offsetHeight || FLOATING_BUTTON_SIZE;
const viewportWidth = this.viewportWidth();
const viewportHeight = this.viewportHeight();
return {
left: Math.max(6, Math.min(Math.round(left), viewportWidth - width - 6)),
top: Math.max(6, Math.min(Math.round(top), viewportHeight - height - 6))
};
},
edgeStateFromPosition(position) {
if (!position || !Number.isFinite(position.left) || !Number.isFinite(position.top)) return null;
const width = this.button?.offsetWidth || FLOATING_BUTTON_SIZE;
const height = this.button?.offsetHeight || FLOATING_BUTTON_SIZE;
const minLeft = 6;
const minTop = 6;
const maxLeft = Math.max(minLeft, this.viewportWidth() - width - 6);
const maxTop = Math.max(minTop, this.viewportHeight() - height - 6);
const distances = [
{ edge: 'left', value: position.left - minLeft },
{ edge: 'right', value: maxLeft - position.left },
{ edge: 'top', value: position.top - minTop },
{ edge: 'bottom', value: maxTop - position.top }
].filter((item) => item.value <= FLOATING_EDGE_DISTANCE);
if (!distances.length) return null;
distances.sort((a, b) => a.value - b.value);
return {
edge: distances[0].edge,
left: Math.max(minLeft, Math.min(Math.round(position.left), maxLeft)),
top: Math.max(minTop, Math.min(Math.round(position.top), maxTop))
};
},
positionFromEdgeState(state, hidden = true) {
const normalized = Prefs.edgeState(state);
if (!normalized) return null;
const width = this.button?.offsetWidth || FLOATING_BUTTON_SIZE;
const height = this.button?.offsetHeight || FLOATING_BUTTON_SIZE;
const visible = this.clampPosition(normalized.left, normalized.top);
if (!hidden) return visible;
if (normalized.edge === 'left') return { left: FLOATING_EDGE_PEEK - width, top: visible.top };
if (normalized.edge === 'right') return { left: this.viewportWidth() - FLOATING_EDGE_PEEK, top: visible.top };
if (normalized.edge === 'top') return { left: visible.left, top: FLOATING_EDGE_PEEK - height };
return { left: visible.left, top: this.viewportHeight() - FLOATING_EDGE_PEEK };
},
applyEdgeState(state, options = {}) {
if (!this.root) return false;
const normalized = Prefs.edgeState(state);
if (!normalized) {
this.clearEdgeClasses();
return false;
}
const hidden = options.hidden !== false;
const position = this.positionFromEdgeState(normalized, hidden);
if (!position) return false;
this.root.style.left = `${position.left}px`;
this.root.style.top = `${position.top}px`;
this.root.style.right = 'auto';
this.root.style.bottom = 'auto';
this.root.classList.add('is-positioned');
this.setEdgeClasses(normalized.edge, hidden);
this.applyPanelPlacement(normalized.left, normalized.top);
if (this.isPieOpen()) this.syncPieViewportOffset();
this.syncPieLayout();
return true;
},
revealEdgeState() {
if (!Prefs.value.prefEdgeState || this.isPieOpen()) return false;
return this.applyEdgeState(Prefs.value.prefEdgeState, { hidden: false });
},
collapseEdgeStateIfIdle() {
if (!Prefs.value.prefEdgeState || this.drag || this.isInteractionOpen()) return false;
return this.applyEdgeState(Prefs.value.prefEdgeState);
},
isInteractionOpen() {
return this.isOpen() || this.isPieOpen() || LiveTopics.isOpen();
},
isEdgeHidden() {
return this.root?.classList.contains('is-edge-hidden') === true;
},
setEdgeClasses(edge, hidden) {
if (!this.root) return;
this.clearEdgeClasses();
this.root.classList.add(`is-edge-${edge}`);
this.root.classList.toggle('is-edge-hidden', hidden);
},
clearEdgeClasses() {
if (!this.root) return;
this.root.classList.remove(
'is-edge-hidden',
'is-edge-left',
'is-edge-right',
'is-edge-top',
'is-edge-bottom'
);
},
viewportWidth() {
return Math.max(0, document.documentElement?.clientWidth || window.innerWidth || 0);
},
viewportHeight() {
return Math.max(0, document.documentElement?.clientHeight || window.innerHeight || 0);
},
applyPanelPlacement(left = this.root?.getBoundingClientRect().left, top = this.root?.getBoundingClientRect().top) {
if (!this.root) return;
const rect = this.root.getBoundingClientRect();
const x = Number.isFinite(left) ? left : rect.left;
const y = Number.isFinite(top) ? top : rect.top;
this.root.classList.toggle('is-panel-left', x < 360);
this.root.classList.toggle('is-panel-below', y < 360);
this.syncPieLayout();
},
resetPieViewportOffset() {
if (!this.root) return;
this.root.style.removeProperty('--pie-shift-x');
this.root.style.removeProperty('--pie-shift-y');
this.menu?.classList.remove('is-shifted');
},
syncPieViewportOffset() {
if (!this.root || !this.menu) return;
const rect = this.root.getBoundingClientRect();
const buttonWidth = rect.width || this.button?.offsetWidth || FLOATING_BUTTON_SIZE;
const buttonHeight = rect.height || this.button?.offsetHeight || FLOATING_BUTTON_SIZE;
const margin = 14;
const radius = FLOATING_PIE_RADIUS + 6;
const centerX = rect.left + buttonWidth / 2;
const centerY = rect.top + buttonHeight / 2;
const nextCenterX = Math.max(radius + margin, Math.min(centerX, this.viewportWidth() - radius - margin));
const nextCenterY = Math.max(radius + margin, Math.min(centerY, this.viewportHeight() - radius - margin));
const shiftX = Math.round(nextCenterX - centerX);
const shiftY = Math.round(nextCenterY - centerY);
this.root.style.setProperty('--pie-shift-x', `${shiftX}px`);
this.root.style.setProperty('--pie-shift-y', `${shiftY}px`);
this.menu.classList.toggle('is-shifted', Math.abs(shiftX) > 1 || Math.abs(shiftY) > 1);
},
syncPieLayout() {
if (!this.root || !this.button || !this.menu) return;
const items = Array.from(this.menu.querySelectorAll('[data-pie-action]'));
if (!items.length) return;
const radius = items.length > 4 ? 74 : 70;
const startAngle = -92;
const step = items.length > 1 ? 360 / items.length : 0;
const span = Math.max(34, Math.min(54, step ? step - 18 : 54));
this.pieHighlightSpan = span;
this.root.classList.toggle('is-pie-left', false);
this.root.classList.toggle('is-pie-up', false);
items.forEach((item, index) => {
const angle = startAngle + index * step;
const radian = angle * Math.PI / 180;
item.style.setProperty('--pie-x', `${Math.round(Math.cos(radian) * radius)}px`);
item.style.setProperty('--pie-y', `${Math.round(Math.sin(radian) * radius)}px`);
item.style.setProperty('--pie-delay', `${index * 24}ms`);
item.dataset.pieAngle = String(angle);
item.dataset.pieSpan = String(span);
});
this.restorePieHighlight();
}
};
const CrossTabSync = {
started: false,
timer: 0,
pendingKinds: new Set(),
keys: new Map([
[PREF_KEY, 'prefs'],
[MEMORY_KEY, 'readMemory'],
[FAVORITES_KEY, 'favorites'],
[READ_LATER_KEY, 'readLater'],
[PAGE_NAV_KEY, 'pageNav'],
[DRAWER_STATE_KEY, 'drawerState']
]),
start() {
if (this.started) return;
this.started = true;
this.watchGMValues();
this.watchLocalStorage();
this.watchPageResume();
},
watchGMValues() {
if (typeof GM_addValueChangeListener !== 'function') return;
this.keys.forEach((kind, key) => {
try {
GM_addValueChangeListener(key, (_name, oldValue, newValue, remote) => {
if (remote === false || oldValue === newValue) return;
this.schedule(kind);
});
} catch (error) {
console.warn(`${LOG_PREFIX} 跨标签同步监听失败`, key, error);
}
});
},
watchLocalStorage() {
window.addEventListener('storage', (event) => {
if (event.storageArea && event.storageArea !== window.localStorage) return;
const kind = this.keys.get(event.key);
if (kind) this.schedule(kind);
});
},
watchPageResume() {
const syncAll = () => this.schedule('all');
window.addEventListener('focus', syncAll, { passive: true });
window.addEventListener('pageshow', syncAll, { passive: true });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) syncAll();
});
},
schedule(kind = 'all') {
this.pendingKinds.add(kind);
if (this.timer) return;
this.timer = window.setTimeout(() => this.flush(), 80);
},
flush() {
this.timer = 0;
const kinds = new Set(this.pendingKinds);
this.pendingKinds.clear();
if (kinds.has('all')) {
this.reloadAll();
return;
}
this.reloadKinds(kinds);
this.refreshSurfaces(kinds);
},
reloadAll() {
const kinds = new Set(['prefs', 'readMemory', 'favorites', 'readLater', 'pageNav', 'drawerState']);
this.reloadKinds(kinds);
this.refreshSurfaces(kinds);
},
reloadKinds(kinds) {
['prefs', 'readMemory', 'favorites', 'readLater', 'pageNav', 'drawerState'].forEach((kind) => {
if (kinds.has(kind)) this.reloadKind(kind);
});
},
reloadKind(kind) {
if (kind === 'prefs') Prefs.load();
else if (kind === 'readMemory') {
ReadMemory.load();
SimilarTopics.invalidate();
} else if (kind === 'favorites') Favorites.load();
else if (kind === 'readLater') ReadLaterQueue.load();
else if (kind === 'pageNav') PageNav.load();
else if (kind === 'drawerState') DrawerState.load();
},
refreshSurfaces(kinds) {
if (kinds.has('prefs') && Prefs.value.forceCreatedOrder && CreatedOrder.apply()) return;
if (kinds.has('prefs')) {
StarterPostStore.trimCache();
StarterPostStore.trimPreloadMarks();
}
if (kinds.has('prefs') || kinds.has('readMemory')) {
TopicBadges.refresh();
}
MiniEye.syncLaterButton(MiniEye.button?.dataset.topicId || '');
Drawer.applySize();
Drawer.syncControls();
Drawer.renderSidebar();
FloatingPrefs.applyPosition();
FloatingPrefs.applyTheme();
FloatingPrefs.sync();
}
};
// 将标准化后的 Discourse 首帖数据渲染成预览卡片。
const SummaryCard = {
build(topic) {
const summaries = Array.isArray(topic.summaries) && topic.summaries.length
? topic.summaries
: [{
key: 'starter',
label: '楼主',
displayName: topic.displayName,
username: topic.username,
avatar: topic.avatar,
createdAt: topic.createdAt,
likes: topic.likes,
postNumber: 1,
cooked: topic.cooked
}];
const activeKey = summaries.find((item) => !item.empty)?.key || summaries[0]?.key || 'starter';
const tabs = Dom.make('div', { className: `${NAME}-summary-tabs`, role: 'tablist', 'aria-label': '回复摘要切换' },
summaries.map((summary) => Dom.make('button', {
type: 'button',
className: `${NAME}-summary-tab${summary.key === activeKey ? ' is-active' : ''}`,
role: 'tab',
'aria-selected': summary.key === activeKey ? 'true' : 'false',
'data-summary-tab': summary.key,
title: summary.empty ? summary.emptyText : summary.label
}, [
Dom.make('span', { className: `${NAME}-summary-tab-label`, text: summary.label }),
summary.badge
? Dom.make('span', { className: `${NAME}-summary-tab-badge`, text: summary.badge })
: null
])));
return Dom.make('article', { className: `${NAME}-summary` }, [
tabs,
Dom.make('div', { className: `${NAME}-summary-hint`, text: this.hintText(topic) }),
this.searchBox(),
...summaries.map((summary) => this.view(topic, summary, summary.key === activeKey)),
Dom.make('div', { className: `${NAME}-actions` }, [
Dom.make('button', { className: `${NAME}-primary-btn`, type: 'button', 'data-mode': 'thread', text: '查看详情' }),
Dom.make('a', {
className: `${NAME}-soft-btn`,
href: Urls.canonicalTopic(topic.id),
target: '_blank',
rel: 'noopener noreferrer',
text: '打开原帖'
})
])
]);
},
searchBox() {
return Dom.make('div', { className: `${NAME}-summary-search`, role: 'search', 'aria-label': '预览内容搜索' }, [
Dom.make('input', {
className: `${NAME}-summary-search-input`,
type: 'search',
placeholder: '搜索当前摘要',
autocomplete: 'off',
spellcheck: 'false',
'data-summary-search': '1'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-summary-search-btn`,
title: '上一处',
'aria-label': '上一处',
'data-summary-search-step': '-1',
disabled: 'disabled',
text: '‹'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-summary-search-btn`,
title: '下一处',
'aria-label': '下一处',
'data-summary-search-step': '1',
disabled: 'disabled',
text: '›'
}),
Dom.make('button', {
type: 'button',
className: `${NAME}-summary-search-btn`,
title: '清空搜索',
'aria-label': '清空搜索',
'data-summary-search-clear': '1',
disabled: 'disabled',
text: '×'
}),
Dom.make('span', { className: `${NAME}-summary-search-count`, 'data-summary-search-count': '1', text: '0/0' })
]);
},
hintText(topic) {
const loaded = Number(topic.loadedReplyCount || 0);
const total = Number(topic.replyCount || 0);
if (total > loaded) {
return `提示:高赞回复和最新回复仅基于当前预览已加载的 ${loaded} 条回复,全帖约 ${total} 条回复,可能不是全量最新;完整阅读请查看详情。`;
}
if (loaded > 0) {
return `提示:高赞回复和最新回复基于当前预览接口返回的 ${loaded} 条回复,可能与详情页实时排序存在差异。`;
}
return '提示:当前预览未加载回复,高赞回复和最新回复可能为空;完整阅读请查看详情。';
},
view(topic, summary, active) {
if (summary.empty) {
return Dom.make('section', {
className: `${NAME}-summary-view${active ? ' is-active' : ''}`,
role: 'tabpanel',
'data-summary-view': summary.key,
hidden: active ? null : 'hidden'
}, [
Dom.make('div', { className: `${NAME}-summary-empty`, text: summary.emptyText || '暂无内容' })
]);
}
const avatar = summary.avatar
? Dom.make('img', { className: `${NAME}-avatar`, src: summary.avatar, alt: '' })
: Dom.make('span', { className: `${NAME}-avatar`, text: (summary.displayName || '?').charAt(0).toUpperCase() });
const createdAt = summary.createdAt
? new Date(summary.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
: '';
const author = Dom.make('div', { className: `${NAME}-author` }, [
avatar,
Dom.make('div', { className: `${NAME}-author-copy` }, [
Dom.make('div', { className: `${NAME}-name`, text: summary.displayName || '未知用户' }),
Dom.make('div', { className: `${NAME}-username`, text: summary.username ? `@${summary.username}` : '' })
])
]);
const metaItems = [];
if (summary.postNumber) metaItems.push(Dom.make('span', { text: `楼层:#${summary.postNumber}` }));
if (createdAt) metaItems.push(Dom.make('span', { text: `时间:${createdAt}` }));
metaItems.push(Dom.make('span', { text: `点赞数:${summary.likes || 0}` }));
if (Number(topic.replyCount) > 0) metaItems.push(Dom.make('span', { text: `回复数:${topic.replyCount}` }));
return Dom.make('section', {
className: `${NAME}-summary-view${active ? ' is-active' : ''}`,
role: 'tabpanel',
'data-summary-view': summary.key,
hidden: active ? null : 'hidden'
}, [
Dom.make('header', { className: `${NAME}-topic-head` }, [
author,
Dom.make('h2', { className: `${NAME}-topic-title`, text: topic.title }),
Dom.make('div', { className: `${NAME}-meta` }, metaItems)
]),
Dom.make('section', { className: `${NAME}-cooked`, html: summary.cooked || '<p>暂无正文内容</p>' })
]);
},
activate(card, key) {
if (!card || !key) return;
card.querySelectorAll('[data-summary-tab]').forEach((button) => {
const active = button.dataset.summaryTab === key;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
card.querySelectorAll('[data-summary-view]').forEach((view) => {
const active = view.dataset.summaryView === key;
view.classList.toggle('is-active', active);
view.toggleAttribute('hidden', !active);
});
const input = card.querySelector('[data-summary-search]');
if (input?.value) this.search(card, input.value);
},
search(card, query) {
if (!card) return;
const value = String(query || '').trim();
const input = card.querySelector('[data-summary-search]');
if (input && input.value !== query) input.value = query || '';
this.clearMarks(card);
if (!value) {
this.updateSearchState(card, 0, 0, false);
return;
}
const root = card.querySelector(`.${NAME}-summary-view.is-active .${NAME}-cooked`);
if (!root) {
this.updateSearchState(card, 0, 0, true);
return;
}
this.highlight(root, value);
const marks = this.marks(card);
if (!marks.length) {
this.updateSearchState(card, 0, 0, true);
return;
}
this.setCurrent(card, 0, { scroll: true });
},
step(card, direction) {
if (!card) return;
const marks = this.marks(card);
if (!marks.length) return;
const current = Number(card.dataset.summarySearchIndex || 0);
const next = (current + Number(direction || 0) + marks.length) % marks.length;
this.setCurrent(card, next, { scroll: true });
},
clearSearch(card) {
if (!card) return;
const input = card.querySelector('[data-summary-search]');
if (input) input.value = '';
this.clearMarks(card);
this.updateSearchState(card, 0, 0, false);
input?.focus();
},
highlight(root, query) {
const needle = query.toLowerCase();
const nodes = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue || '';
if (!text.trim()) return NodeFilter.FILTER_REJECT;
if (node.parentElement?.closest?.(`.${NAME}-summary-search-hit`)) return NodeFilter.FILTER_REJECT;
return text.toLowerCase().includes(needle)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach((node) => {
const text = node.nodeValue || '';
const lower = text.toLowerCase();
const fragment = document.createDocumentFragment();
let cursor = 0;
let index = lower.indexOf(needle);
while (index >= 0) {
if (index > cursor) fragment.append(document.createTextNode(text.slice(cursor, index)));
fragment.append(Dom.make('mark', { className: `${NAME}-summary-search-hit`, text: text.slice(index, index + query.length) }));
cursor = index + query.length;
index = lower.indexOf(needle, cursor);
}
if (cursor < text.length) fragment.append(document.createTextNode(text.slice(cursor)));
node.replaceWith(fragment);
});
},
clearMarks(card) {
card.querySelectorAll(`.${NAME}-summary-search-hit`).forEach((mark) => {
const parent = mark.parentNode;
mark.replaceWith(document.createTextNode(mark.textContent || ''));
parent?.normalize?.();
});
card.dataset.summarySearchIndex = '0';
},
marks(card) {
return Array.from(card.querySelectorAll(`.${NAME}-summary-view.is-active .${NAME}-summary-search-hit`));
},
setCurrent(card, index, options = {}) {
const marks = this.marks(card);
marks.forEach((mark) => mark.classList.remove('is-current'));
if (!marks.length) {
this.updateSearchState(card, 0, 0, true);
return;
}
const current = Math.max(0, Math.min(marks.length - 1, Number(index) || 0));
const mark = marks[current];
mark.classList.add('is-current');
card.dataset.summarySearchIndex = String(current);
this.updateSearchState(card, current + 1, marks.length, true);
if (options.scroll !== false) {
mark.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
}
},
updateSearchState(card, current, total, hasQuery) {
const count = card.querySelector('[data-summary-search-count]');
if (count) count.textContent = `${current}/${total}`;
card.querySelectorAll('[data-summary-search-step]').forEach((button) => {
button.disabled = total < 2;
button.toggleAttribute('disabled', total < 2);
});
const clear = card.querySelector('[data-summary-search-clear]');
if (clear) {
clear.disabled = !hasQuery;
clear.toggleAttribute('disabled', !hasQuery);
}
}
};
// 集中处理页面级交互:话题悬浮识别、外部点击关闭和键盘关闭。
const Interactions = {
bind() {
document.addEventListener('pointerover', (event) => this.onPointerOver(event), true);
document.addEventListener('pointerout', (event) => this.onPointerOut(event), true);
document.addEventListener('focusin', (event) => this.onFocus(event), true);
document.addEventListener('click', (event) => this.onTopicClick(event), true);
document.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
if (Dom.isOwnSurface(event.target)) return;
const hit = TopicHitTest.fromPointerTarget(event.target);
if (hit) return;
MiniEye.hide();
}, true);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
if (Drawer.root?.classList.contains('is-open')) Drawer.close();
else if (LiveTopics.isOpen()) LiveTopics.hide();
else MiniEye.hide();
}
});
window.addEventListener('scroll', () => MiniEye.place(), { passive: true });
window.addEventListener('resize', () => {
Drawer.applyTheme();
Drawer.applySize();
FloatingPrefs.applyTheme();
FloatingPrefs.applyPosition();
MiniEye.applyTheme();
MiniEye.place();
}, { passive: true });
},
onTopicClick(event) {
if (!Prefs.value.clickTopicToDrawer || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (Dom.isOwnSurface(event.target)) return;
const hit = TopicHitTest.fromPointerTarget(event.target);
if (!hit) return;
if (this.shouldLetBrowserOpen(hit.anchor)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
MiniEye.hide();
TopicBadges.refreshAnchor(hit.anchor);
Drawer.open(hit.topicId, Prefs.value.mode, hit.anchor, hit.anchor.getAttribute('href') || '', { trackSource: 'topicLinks' });
},
shouldLetBrowserOpen(anchor) {
if (!anchor?.matches?.('a[href]')) return true;
if (anchor.hasAttribute('download')) return true;
const target = String(anchor.getAttribute('target') || '').trim().toLowerCase();
return !!target && target !== '_self';
},
onPointerOver(event) {
if (window.innerWidth <= 768) return;
const hit = TopicHitTest.fromPointerTarget(event.target);
if (!hit) return;
TopicBadges.refreshAnchor(hit.anchor);
MiniEye.show(hit.topicId, hit.anchor);
},
onPointerOut(event) {
if (window.innerWidth <= 768) return;
const hit = TopicHitTest.fromPointerTarget(event.target);
if (!hit) return;
if (event.relatedTarget instanceof Node && hit.anchor.contains(event.relatedTarget)) return;
if (Dom.isOwnSurface(event.relatedTarget)) return;
MiniEye.hideSoon();
},
onFocus(event) {
if (window.innerWidth <= 768) return;
const hit = TopicHitTest.fromPointerTarget(event.target);
if (!hit) return;
TopicBadges.refreshAnchor(hit.anchor);
MiniEye.show(hit.topicId, hit.anchor);
}
};
// 样式一次性注入,让脚本保持单文件形态,便于 GitHub 直接安装。
function installStyle() {
const css = `
#${NAME}-eye,
#${NAME}-eye *,
#${NAME}-mini-stats,
#${NAME}-mini-stats *,
#${NAME}-drawer,
#${NAME}-drawer-sidebar,
#${NAME}-drawer-sidebar *,
#${NAME}-shade,
#${NAME}-prefs,
#${NAME}-prefs *,
#${NAME}-drawer * {
box-sizing: border-box;
}
#${NAME}-eye {
--blue: #5e7ee8;
--green: #4eb4a5;
position: fixed;
left: 0;
top: 0;
width: auto;
height: 36px;
border: 0;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 3px;
padding: 3px;
color: #fff;
background:
linear-gradient(180deg, rgba(255, 255, 255, .28), rgba(255, 255, 255, .08)),
linear-gradient(135deg, rgba(94, 126, 232, .86), rgba(78, 180, 165, .86));
box-shadow:
0 14px 34px rgba(20, 28, 48, .28),
0 4px 12px rgba(20, 28, 48, .18),
inset 0 1px 0 rgba(255, 255, 255, .42),
inset 0 0 0 1px rgba(255, 255, 255, .24);
z-index: 100000;
opacity: 0;
pointer-events: none;
transform: translate(-999px, -999px) scale(.92);
backdrop-filter: blur(16px) saturate(1.35);
-webkit-backdrop-filter: blur(16px) saturate(1.35);
transition: opacity 150ms ease, box-shadow 150ms ease, filter 150ms ease;
-webkit-tap-highlight-color: transparent;
}
#${NAME}-eye::before {
content: '';
position: absolute;
inset: 1px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, .22), transparent 48%);
pointer-events: none;
}
#${NAME}-eye.is-live {
opacity: 1;
pointer-events: auto;
}
#${NAME}-eye:hover,
#${NAME}-eye:focus-within {
filter: brightness(1.05);
box-shadow:
0 18px 42px rgba(20, 28, 48, .36),
0 0 0 3px rgba(94, 126, 232, .2),
inset 0 1px 0 rgba(255, 255, 255, .5),
inset 0 0 0 1px rgba(255, 255, 255, .3);
}
.${NAME}-eye-btn {
appearance: none;
width: 36px;
height: 30px;
border: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 19px;
font-weight: 850;
line-height: 1;
}
.${NAME}-eye-btn:hover,
.${NAME}-eye-btn:focus-visible {
outline: none;
background: rgba(255, 255, 255, .18);
}
.${NAME}-eye-later.is-active {
color: #1f2941;
background: rgba(255, 255, 255, .92);
}
.${NAME}-eye-stats-icon {
position: relative;
display: block;
width: 24px;
height: 22px;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .22));
}
.${NAME}-eye-stats-icon::before {
content: '';
position: absolute;
left: 4px;
bottom: 5px;
width: 4px;
height: 8px;
border-radius: 3px 3px 1px 1px;
background: rgba(255, 255, 255, .96);
box-shadow:
7px -5px 0 rgba(255, 255, 255, .9),
14px -2px 0 rgba(255, 255, 255, .82);
}
.${NAME}-eye-stats-icon::after {
content: '';
position: absolute;
left: 3px;
right: 2px;
bottom: 3px;
height: 2px;
border-radius: 999px;
background: rgba(255, 255, 255, .78);
}
#${NAME}-mini-stats {
--mini-bg: rgba(18, 22, 34, .97);
--mini-panel: rgba(255, 255, 255, .075);
--mini-line: rgba(255, 255, 255, .13);
--mini-text: #f5f7fb;
--mini-muted: #aab3c8;
--mini-blue: #7b95ff;
--mini-green: #55c3aa;
--mini-warn: #ffbe73;
position: fixed;
left: 0;
top: 0;
z-index: 100001;
width: min(306px, calc(100vw - 16px));
color: var(--mini-text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
opacity: 0;
pointer-events: none;
transform: translate(-999px, -999px);
transition: opacity 150ms ease, filter 150ms ease;
-webkit-tap-highlight-color: transparent;
}
#${NAME}-mini-stats.is-light {
--mini-bg: rgba(255, 255, 255, .98);
--mini-panel: rgba(245, 247, 252, .96);
--mini-line: rgba(24, 33, 52, .13);
--mini-text: #202636;
--mini-muted: #596276;
--mini-blue: #526fd6;
--mini-green: #258a7b;
--mini-warn: #b45309;
}
#${NAME}-mini-stats.is-open {
opacity: 1;
pointer-events: auto;
}
.${NAME}-mini-stats-card {
overflow: hidden;
border: 1px solid var(--mini-line);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, .08), transparent 44%),
var(--mini-bg);
box-shadow:
0 18px 44px rgba(14, 20, 34, .34),
0 8px 18px rgba(14, 20, 34, .22),
inset 0 1px 0 rgba(255, 255, 255, .12);
backdrop-filter: blur(16px) saturate(1.25);
-webkit-backdrop-filter: blur(16px) saturate(1.25);
}
#${NAME}-mini-stats.is-light .${NAME}-mini-stats-card {
background:
linear-gradient(180deg, rgba(255, 255, 255, .92), transparent 46%),
var(--mini-bg);
box-shadow:
0 18px 42px rgba(31, 41, 58, .14),
0 8px 18px rgba(31, 41, 58, .1),
inset 0 1px 0 rgba(255, 255, 255, .8);
}
.${NAME}-mini-stats-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px 8px;
border-bottom: 1px solid var(--mini-line);
}
.${NAME}-mini-stats-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 750;
color: var(--mini-text);
}
.${NAME}-mini-stats-close {
appearance: none;
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 7px;
color: var(--mini-muted);
background: transparent;
cursor: pointer;
font: 18px/1 Arial, sans-serif;
}
.${NAME}-mini-stats-close:hover,
.${NAME}-mini-stats-close:focus-visible {
outline: none;
color: var(--mini-text);
background: rgba(255, 255, 255, .1);
}
#${NAME}-mini-stats.is-light .${NAME}-mini-stats-close:hover,
#${NAME}-mini-stats.is-light .${NAME}-mini-stats-close:focus-visible {
background: rgba(24, 33, 52, .08);
}
.${NAME}-mini-stats-empty {
padding: 18px 12px;
color: var(--mini-muted);
text-align: center;
}
.${NAME}-mini-stats-body {
display: flex;
flex-direction: column;
gap: 9px;
padding: 10px;
}
.${NAME}-mini-stats-id-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: stretch;
}
.${NAME}-mini-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.${NAME}-mini-stats-row,
.${NAME}-mini-stats-status {
min-width: 0;
border: 1px solid var(--mini-line);
border-radius: 8px;
background: var(--mini-panel);
}
.${NAME}-mini-stats-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 8px;
color: inherit;
text-align: left;
}
button.${NAME}-mini-stats-row {
appearance: none;
cursor: pointer;
font: inherit;
}
button.${NAME}-mini-stats-row:hover,
button.${NAME}-mini-stats-row:focus-visible {
outline: none;
border-color: rgba(123, 149, 255, .48);
background: rgba(123, 149, 255, .16);
}
#${NAME}-mini-stats.is-light button.${NAME}-mini-stats-row:hover,
#${NAME}-mini-stats.is-light button.${NAME}-mini-stats-row:focus-visible {
border-color: rgba(82, 111, 214, .42);
background: rgba(82, 111, 214, .1);
}
.${NAME}-mini-stats-row-warn .${NAME}-mini-stats-value {
color: var(--mini-warn);
}
.${NAME}-mini-stats-label {
overflow: hidden;
color: var(--mini-muted);
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
}
.${NAME}-mini-stats-value {
overflow: hidden;
color: var(--mini-text);
font-size: 14px;
font-weight: 760;
white-space: nowrap;
text-overflow: ellipsis;
}
.${NAME}-mini-stats-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 58px;
padding: 0 9px;
color: var(--mini-green);
font-size: 12px;
font-weight: 720;
}
.${NAME}-mini-stats-meter {
border: 1px solid var(--mini-line);
border-radius: 8px;
padding: 7px 8px 8px;
background: var(--mini-panel);
}
.${NAME}-mini-stats-meter-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.${NAME}-mini-stats-meter-track {
overflow: hidden;
height: 7px;
border-radius: 999px;
background: rgba(255, 255, 255, .11);
}
#${NAME}-mini-stats.is-light .${NAME}-mini-stats-meter-track {
background: rgba(24, 33, 52, .08);
}
.${NAME}-mini-stats-meter-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--mini-blue), var(--mini-green));
}
.${NAME}-mini-stats-meter-warn .${NAME}-mini-stats-meter-fill {
background: linear-gradient(90deg, var(--mini-warn), #f07178);
}
.${NAME}-mini-stats-drawer {
appearance: none;
height: 30px;
border: 1px solid rgba(123, 149, 255, .42);
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, rgba(123, 149, 255, .76), rgba(85, 195, 170, .72));
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 760;
}
.${NAME}-mini-stats-drawer:hover,
.${NAME}-mini-stats-drawer:focus-visible {
outline: none;
filter: brightness(1.08);
}
.${NAME}-book-preview {
position: relative;
z-index: 1;
width: 30px;
height: 23px;
perspective: 80px;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .25));
}
.${NAME}-book-preview::before {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 5px;
height: 19px;
border-radius: 3px;
background: rgba(255, 255, 255, .95);
box-shadow: 0 0 0 1px rgba(255, 255, 255, .2);
}
.${NAME}-book-pages,
.${NAME}-book-cover {
position: absolute;
left: 6px;
top: 2px;
width: 22px;
height: 19px;
border-radius: 2px 6px 6px 2px;
transform-origin: left center;
transition: transform 240ms cubic-bezier(.22, 1, .36, 1), opacity 180ms ease, box-shadow 180ms ease;
}
.${NAME}-book-pages {
background:
linear-gradient(90deg, rgba(255, 255, 255, .9) 0 1px, transparent 1px 100%),
repeating-linear-gradient(180deg, rgba(90, 110, 150, .28) 0 1px, transparent 1px 4px),
linear-gradient(180deg, #fff 0%, #e8edf8 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .7);
opacity: .72;
transform: rotateY(16deg) translateX(0);
}
.${NAME}-book-cover {
background:
linear-gradient(90deg, rgba(255, 255, 255, .28) 0 2px, transparent 2px),
linear-gradient(135deg, #ffffff 0%, #dce7ff 52%, #b9caf4 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .7), 0 2px 5px rgba(0, 0, 0, .18);
transform: rotateY(0deg);
}
.${NAME}-book-cover::after {
content: '';
position: absolute;
right: 5px;
top: 6px;
width: 9px;
height: 2px;
border-radius: 2px;
background: rgba(76, 95, 140, .48);
box-shadow: 0 5px 0 rgba(76, 95, 140, .34);
}
#${NAME}-eye:hover .${NAME}-book-cover,
#${NAME}-eye:focus-visible .${NAME}-book-cover {
transform: rotateY(-58deg) translateX(-1px);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .7), 3px 3px 8px rgba(0, 0, 0, .18);
}
#${NAME}-eye:hover .${NAME}-book-pages,
#${NAME}-eye:focus-visible .${NAME}-book-pages {
opacity: 1;
transform: rotateY(0deg) translateX(2px);
}
#${NAME}-prefs {
--bg: rgba(18, 20, 29, .98);
--panel: rgba(30, 33, 45, .94);
--panel2: rgba(39, 43, 58, .92);
--text: #eef1f7;
--muted: #a0a7ba;
--weak: #6f778b;
--line: rgba(255, 255, 255, .09);
--blue: #6385ef;
--green: #56b8aa;
--rail-bg: var(--bg);
--rail-line: var(--line);
--rail-text: var(--text);
--rail-muted: var(--muted);
--rail-button: var(--panel2);
--rail-button-hover: rgba(99, 133, 239, .14);
--rail-button-active: rgba(99, 133, 239, .18);
--active-border: rgba(99, 133, 239, .42);
--badge: var(--green);
--pie-disc-bg: rgba(18, 20, 29, .94);
--pie-disc-center: rgba(7, 10, 20, .74);
--pie-inner-line: rgba(255, 255, 255, .10);
--pie-sector: rgba(255, 255, 255, .13);
--pie-sector-line: rgba(255, 255, 255, .08);
--pie-ease-out: cubic-bezier(.16, 1, .3, 1);
--pie-ease-back: cubic-bezier(.34, 1.56, .64, 1);
position: fixed;
right: 22px;
bottom: 22px;
width: 40px;
height: 40px;
z-index: 100000;
display: block;
color: var(--text);
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
isolation: isolate;
}
#${NAME}-prefs.is-light {
--bg: rgba(250, 251, 254, .98);
--panel: rgba(244, 247, 252, .96);
--panel2: rgba(255, 255, 255, .96);
--text: #202636;
--muted: #596276;
--weak: #8992a4;
--line: rgba(24, 33, 52, .11);
--rail-bg: rgba(250, 251, 254, .98);
--rail-button: rgba(255, 255, 255, .98);
--rail-muted: #596276;
--rail-text: #202636;
--rail-line: rgba(24, 33, 52, .12);
--pie-disc-bg: rgba(250, 251, 254, .94);
--pie-disc-center: rgba(255, 255, 255, .82);
--pie-inner-line: rgba(24, 33, 52, .12);
--pie-sector: rgba(24, 33, 52, .12);
--pie-sector-line: rgba(24, 33, 52, .09);
}
.${NAME}-pref-button {
position: absolute;
inset: 0;
z-index: 4;
width: 40px;
height: 40px;
border: 1px solid var(--rail-line);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-muted);
background: var(--rail-bg);
box-shadow: 0 12px 30px rgba(0, 0, 0, .26), inset 0 1px 0 rgba(255, 255, 255, .06);
cursor: pointer;
touch-action: none;
user-select: none;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
will-change: transform, opacity, filter;
transition:
opacity 150ms ease,
filter 150ms ease,
color 130ms ease,
background 130ms ease,
border-radius 160ms ease,
transform 180ms cubic-bezier(.2, .8, .2, 1),
box-shadow 160ms ease;
}
.${NAME}-pref-button::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
border: 0;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(.82);
transition: opacity 140ms ease, transform 180ms cubic-bezier(.2, .8, .2, 1);
}
#${NAME}-prefs.is-edge-hidden .${NAME}-pref-button {
color: #f3f7ff;
background: linear-gradient(145deg, rgba(74, 82, 96, .98), rgba(28, 34, 43, .98));
border-color: rgba(125, 152, 190, .58);
border-radius: 999px;
box-shadow:
0 10px 24px rgba(7, 12, 20, .36),
0 0 0 1px rgba(96, 165, 250, .18),
inset 0 1px 0 rgba(255, 255, 255, .22),
inset 0 -10px 18px rgba(5, 8, 13, .22);
transform: none;
}
#${NAME}-prefs.is-edge-hidden .${NAME}-pref-button:hover,
#${NAME}-prefs.is-edge-hidden .${NAME}-pref-button:focus-visible {
color: #fff;
background: linear-gradient(145deg, rgba(86, 96, 112, .98), rgba(35, 43, 55, .98));
border-color: rgba(147, 197, 253, .72);
box-shadow:
0 12px 28px rgba(7, 12, 20, .40),
0 0 0 1px rgba(96, 165, 250, .30),
0 0 18px rgba(96, 165, 250, .18),
inset 0 1px 0 rgba(255, 255, 255, .26),
inset 0 -10px 18px rgba(5, 8, 13, .20);
transform: none;
}
#${NAME}-prefs.is-edge-hidden .${NAME}-launcher-symbol {
opacity: 0;
transform: scale(.72);
}
#${NAME}-prefs.is-edge-hidden .${NAME}-pref-button::after {
opacity: 1;
transform: translate(0, 0) scale(1);
}
#${NAME}-prefs.is-edge-hidden.is-edge-left .${NAME}-pref-button::after {
left: auto;
right: 3px;
top: 50%;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 10px solid #f3f7ff;
transform: translateY(-50%);
}
#${NAME}-prefs.is-edge-hidden.is-edge-right .${NAME}-pref-button::after {
left: 3px;
top: 50%;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-right: 10px solid #f3f7ff;
transform: translateY(-50%);
}
#${NAME}-prefs.is-edge-hidden.is-edge-top .${NAME}-pref-button::after {
left: 50%;
top: auto;
bottom: 3px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid #f3f7ff;
transform: translateX(-50%);
}
#${NAME}-prefs.is-edge-hidden.is-edge-bottom .${NAME}-pref-button::after {
left: 50%;
top: 3px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 10px solid #f3f7ff;
transform: translateX(-50%);
}
.${NAME}-pref-button:hover,
.${NAME}-pref-button:focus-visible {
outline: none;
color: var(--rail-text);
background: var(--rail-button-hover);
transform: translateX(1px) scale(1.04);
}
#${NAME}-prefs.is-pie-open .${NAME}-pref-button {
color: var(--rail-text);
background: var(--rail-button-active);
transform: translate3d(var(--pie-shift-x, 0px), var(--pie-shift-y, 0px), 0) scale(1.04);
box-shadow: 0 14px 36px rgba(0, 0, 0, .28), 0 0 0 7px rgba(99, 133, 239, .05), inset 0 0 0 1px var(--active-border);
}
.${NAME}-launcher-symbol {
position: relative;
z-index: 1;
width: 17px;
height: 17px;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 3px;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .28));
transition:
opacity 150ms ease,
gap 240ms var(--pie-ease-out),
transform 360ms cubic-bezier(.22, .98, .33, 1.08);
}
.${NAME}-launcher-dot {
border-radius: 3px;
background: currentColor;
transition:
background 180ms ease,
border-radius 240ms var(--pie-ease-out),
box-shadow 180ms ease,
transform 320ms var(--pie-ease-back);
}
#${NAME}-prefs.is-pie-open .${NAME}-launcher-dot,
.${NAME}-pref-button:hover .${NAME}-launcher-dot,
.${NAME}-pref-button:focus-visible .${NAME}-launcher-dot {
border-radius: 999px;
background: var(--blue);
box-shadow: 0 0 7px rgba(99, 133, 239, .36);
}
#${NAME}-prefs.is-pie-open .${NAME}-launcher-symbol {
gap: 2px;
transform: rotate(45deg) scale(.82);
}
.${NAME}-pie-menu {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
isolation: isolate;
--pie-item-size: 34px;
}
.${NAME}-pie-menu::before {
content: '';
position: absolute;
z-index: 0;
left: calc(50% + var(--pie-shift-x, 0px));
top: calc(50% + var(--pie-shift-y, 0px));
width: 200px;
height: 200px;
border: 1px solid var(--rail-line);
border-radius: 999px;
background:
radial-gradient(circle at 50% 50%,
var(--pie-disc-center) 0 47px,
var(--pie-inner-line) 47px 48px,
transparent 49px),
radial-gradient(circle at 50% 50%,
transparent 0 48px,
var(--pie-disc-bg) 49px 100px,
transparent 101px);
visibility: hidden;
box-shadow: none;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(.86);
transform-origin: center;
transition: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#${NAME}-prefs.is-pie-open .${NAME}-pie-menu::before {
visibility: visible;
box-shadow: 0 16px 42px rgba(0, 0, 0, .32), inset 0 0 0 1px rgba(255, 255, 255, .025);
opacity: 1;
transform: translate(-50%, -50%) scale(1);
transition:
opacity 220ms var(--pie-ease-out),
transform 420ms var(--pie-ease-back);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.${NAME}-pie-menu::after {
content: '';
position: absolute;
z-index: 1;
left: calc(50% + var(--pie-shift-x, 0px));
top: calc(50% + var(--pie-shift-y, 0px));
width: 200px;
height: 200px;
border-radius: 999px;
background:
conic-gradient(from var(--pie-highlight-start, -119deg),
var(--pie-sector-line) 0deg,
var(--pie-sector-line) 1.5deg,
var(--pie-sector) 1.5deg,
var(--pie-sector) calc(var(--pie-highlight-span, 54deg) - 1.5deg),
var(--pie-sector-line) calc(var(--pie-highlight-span, 54deg) - 1.5deg),
var(--pie-sector-line) var(--pie-highlight-span, 54deg),
transparent var(--pie-highlight-span, 54deg),
transparent 360deg);
visibility: hidden;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(.9);
transform-origin: center;
filter: none;
-webkit-mask: radial-gradient(circle, transparent 0 48px, #000 49px 100px, transparent 101px);
mask: radial-gradient(circle, transparent 0 48px, #000 49px 100px, transparent 101px);
transition: none;
}
#${NAME}-prefs.is-pie-open .${NAME}-pie-menu.is-highlighted::after {
visibility: visible;
opacity: 1;
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, .24));
transform: translate(-50%, -50%) scale(1);
transition:
opacity 140ms ease,
transform 220ms var(--pie-ease-out);
}
.${NAME}-pie-item {
appearance: none;
position: absolute;
left: 3px;
top: 3px;
z-index: 2;
width: var(--pie-item-size);
height: var(--pie-item-size);
border: 1px solid transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-muted);
background: transparent;
box-shadow: none;
cursor: pointer;
opacity: 0;
pointer-events: none;
transform: translate3d(var(--pie-shift-x, 0px), var(--pie-shift-y, 0px), 0) scale(.7);
transition:
opacity 190ms var(--pie-ease-out),
transform 360ms var(--pie-ease-back),
border-color 150ms ease,
background 130ms ease,
color 130ms ease,
box-shadow 130ms ease;
transition-delay: 0ms;
will-change: transform, opacity;
}
.${NAME}-pie-item::before {
content: '';
display: none;
}
.${NAME}-pie-item::after {
content: '';
display: none;
}
#${NAME}-prefs.is-pie-open .${NAME}-pie-item {
opacity: 1;
pointer-events: auto;
transform: translate3d(calc(var(--pie-shift-x, 0px) + var(--pie-x, 0px)), calc(var(--pie-shift-y, 0px) + var(--pie-y, 0px)), 0) scale(1);
transition-delay: var(--pie-delay, 0ms);
}
.${NAME}-pie-item:hover::before,
.${NAME}-pie-item:focus-visible::before,
.${NAME}-pie-item.is-active::before {
display: none;
}
.${NAME}-pie-item:hover::after,
.${NAME}-pie-item:focus-visible::after,
.${NAME}-pie-item.is-active::after,
.${NAME}-pie-item.is-enabled::after {
display: none;
}
.${NAME}-pie-item:hover,
.${NAME}-pie-item:focus-visible,
.${NAME}-pie-item.is-active {
outline: none;
color: var(--rail-text);
background: transparent;
border-color: transparent;
box-shadow: none;
transform: translate3d(calc(var(--pie-shift-x, 0px) + var(--pie-x, 0px)), calc(var(--pie-shift-y, 0px) + var(--pie-y, 0px)), 0) scale(1);
}
.${NAME}-pie-item.is-enabled:not(.is-active) {
color: var(--rail-text);
background: transparent;
border-color: transparent;
box-shadow: none;
}
.${NAME}-pie-item-icon {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
transition: filter 170ms ease, transform 220ms var(--pie-ease-out);
}
.${NAME}-pie-item:hover .${NAME}-pie-item-icon,
.${NAME}-pie-item:focus-visible .${NAME}-pie-item-icon,
.${NAME}-pie-item.is-active .${NAME}-pie-item-icon {
filter: brightness(1.12) drop-shadow(0 2px 5px rgba(0, 0, 0, .42));
transform: scale(1.1);
}
.${NAME}-pie-item-label {
position: absolute;
z-index: 4;
left: 50%;
top: calc(100% + 8px);
max-width: 88px;
padding: 4px 8px;
border: 1px solid var(--rail-line);
border-radius: 8px;
color: var(--text);
background: var(--rail-bg);
box-shadow: 0 14px 36px rgba(0, 0, 0, .28);
font-size: 11px;
font-weight: 760;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -3px);
backdrop-filter: blur(16px) saturate(1.35);
-webkit-backdrop-filter: blur(16px) saturate(1.35);
transition: opacity 140ms ease, transform 140ms ease;
}
.${NAME}-pie-item:hover .${NAME}-pie-item-label,
.${NAME}-pie-item:focus-visible .${NAME}-pie-item-label {
opacity: 1;
transform: translate(-50%, 0);
}
.${NAME}-pie-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow:
-8px 0 0 rgba(255, 255, 255, .72),
8px 0 0 rgba(255, 255, 255, .72);
}
.${NAME}-pie-drawer-icon,
.${NAME}-pie-settings-icon,
.${NAME}-pie-queue-icon,
.${NAME}-pie-topics-icon,
.${NAME}-pie-live-icon,
.${NAME}-pie-recent-icon,
.${NAME}-pie-nav-icon,
.${NAME}-pie-rules-icon,
.${NAME}-pie-mode-icon,
.${NAME}-pie-data-icon {
position: relative;
width: 20px;
height: 20px;
display: block;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .22));
}
.${NAME}-pie-drawer-icon::before {
content: '';
position: absolute;
left: 2px;
top: 4px;
width: 14px;
height: 11px;
border: 2px solid currentColor;
border-radius: 4px;
box-sizing: border-box;
}
.${NAME}-pie-drawer-icon::after {
content: '';
position: absolute;
left: 6px;
top: 7px;
width: 7px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 3px 0 currentColor;
opacity: .9;
}
.${NAME}-pie-drawer-icon span {
position: absolute;
left: 4px;
top: 6px;
width: 3px;
height: 8px;
border-radius: 3px;
background: currentColor;
opacity: .34;
}
.${NAME}-pie-settings-icon::before {
content: '';
position: absolute;
inset: 3px;
border: 2px solid currentColor;
border-radius: 999px;
}
.${NAME}-pie-settings-icon::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
border-radius: 999px;
background: currentColor;
transform: translate(-50%, -50%);
}
.${NAME}-pie-settings-icon span {
position: absolute;
left: 8px;
top: 2px;
width: 2px;
height: 4px;
border-radius: 999px;
background: currentColor;
opacity: .86;
transform-origin: 1px 7px;
}
.${NAME}-pie-settings-icon span:nth-child(2) {
transform: rotate(120deg);
}
.${NAME}-pie-settings-icon span:nth-child(3) {
transform: rotate(240deg);
}
.${NAME}-pie-item:hover .${NAME}-pie-settings-icon,
.${NAME}-pie-item:focus-visible .${NAME}-pie-settings-icon,
.${NAME}-pie-item.is-active .${NAME}-pie-settings-icon {
animation: ${NAME}-gear-turn 820ms cubic-bezier(.2, .8, .2, 1);
}
.${NAME}-pie-queue-icon span {
position: absolute;
left: 4px;
width: 12px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .08);
}
.${NAME}-pie-queue-icon span:nth-child(1) {
top: 3px;
}
.${NAME}-pie-queue-icon span:nth-child(2) {
top: 8px;
width: 10px;
}
.${NAME}-pie-queue-icon span:nth-child(3) {
top: 13px;
width: 8px;
}
.${NAME}-pie-topics-icon::before {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 10px;
height: 14px;
border-radius: 4px 4px 5px 5px;
background: currentColor;
transform: rotate(8deg);
box-shadow: -4px 2px 0 currentColor;
opacity: .92;
}
.${NAME}-pie-topics-icon::after {
content: '';
position: absolute;
left: 8px;
top: 6px;
width: 5px;
height: 2px;
border-radius: 999px;
background: var(--rail-button);
box-shadow: 0 4px 0 var(--rail-button);
transform: rotate(8deg);
opacity: .72;
}
.${NAME}-pie-topics-icon span {
position: absolute;
right: 4px;
bottom: 2px;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--badge);
box-shadow: 0 0 0 2px var(--rail-button);
}
.${NAME}-pie-nav-icon::before {
content: '';
position: absolute;
left: 3px;
top: 4px;
width: 14px;
height: 12px;
border: 2px solid currentColor;
border-radius: 4px;
box-sizing: border-box;
}
.${NAME}-pie-nav-icon::after {
content: '';
position: absolute;
left: 6px;
top: 8px;
width: 8px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 4px 0 currentColor;
opacity: .86;
}
.${NAME}-pie-nav-icon span {
position: absolute;
right: 2px;
top: 2px;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--badge);
box-shadow: 0 0 0 2px var(--pie-disc-bg);
}
.${NAME}-pie-rules-icon::before {
content: '';
position: absolute;
left: 3px;
top: 3px;
width: 14px;
height: 9px;
background: currentColor;
clip-path: polygon(0 0, 100% 0, 62% 100%, 38% 100%);
opacity: .92;
}
.${NAME}-pie-rules-icon::after {
content: '';
position: absolute;
left: 8px;
top: 10px;
width: 4px;
height: 6px;
border-radius: 0 0 3px 3px;
background: currentColor;
}
.${NAME}-pie-rules-icon span {
position: absolute;
left: 7px;
top: 15px;
width: 6px;
height: 2px;
border-radius: 999px;
background: currentColor;
opacity: .74;
}
.${NAME}-pie-mode-icon::before,
.${NAME}-pie-mode-icon::after {
content: '';
position: absolute;
top: 4px;
width: 8px;
height: 12px;
border: 2px solid currentColor;
box-sizing: border-box;
}
.${NAME}-pie-mode-icon::before {
left: 2px;
border-radius: 4px 1px 1px 4px;
}
.${NAME}-pie-mode-icon::after {
right: 2px;
border-left: 0;
border-radius: 1px 4px 4px 1px;
opacity: .72;
}
.${NAME}-pie-mode-icon span {
position: absolute;
left: 9px;
top: 6px;
width: 2px;
height: 8px;
border-radius: 999px;
background: currentColor;
opacity: .9;
}
.${NAME}-pie-data-icon::before {
content: '';
position: absolute;
left: 4px;
top: 3px;
width: 12px;
height: 6px;
border: 2px solid currentColor;
border-radius: 999px;
box-sizing: border-box;
}
.${NAME}-pie-data-icon::after {
content: '';
position: absolute;
left: 4px;
top: 6px;
width: 12px;
height: 10px;
border: 2px solid currentColor;
border-top: 0;
border-radius: 0 0 6px 6px;
box-sizing: border-box;
}
.${NAME}-pie-data-icon span {
position: absolute;
left: 6px;
top: 10px;
width: 8px;
height: 2px;
border-radius: 999px;
background: currentColor;
opacity: .7;
box-shadow: 0 4px 0 currentColor;
}
.${NAME}-pie-live-icon::before {
content: '';
position: absolute;
left: 7px;
top: 7px;
width: 5px;
height: 5px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 4px rgba(99, 133, 239, .14);
}
.${NAME}-pie-live-icon::after,
.${NAME}-pie-live-icon span {
content: '';
position: absolute;
inset: 2px;
border: 2px solid currentColor;
border-left-color: transparent;
border-bottom-color: transparent;
border-radius: 999px;
transform: rotate(-28deg);
}
.${NAME}-pie-live-icon span:nth-child(1) {
inset: 5px;
opacity: .72;
}
.${NAME}-pie-live-icon span:nth-child(2) {
inset: 0;
opacity: .34;
}
.${NAME}-pie-recent-icon::before {
content: '';
position: absolute;
left: 3px;
top: 3px;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-radius: 999px;
box-sizing: border-box;
}
.${NAME}-pie-recent-icon::after {
content: '';
position: absolute;
left: 9px;
top: 6px;
width: 2px;
height: 6px;
border-radius: 999px;
background: currentColor;
transform-origin: 1px 4px;
transform: rotate(0deg);
box-shadow: 3px 4px 0 -1px currentColor;
}
.${NAME}-pie-recent-icon span:nth-child(1) {
position: absolute;
left: 1px;
top: 8px;
width: 5px;
height: 5px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
.${NAME}-pie-recent-icon span:nth-child(2) {
position: absolute;
left: 1px;
top: 9px;
width: 5px;
height: 2px;
border-radius: 999px;
background: currentColor;
}
.${NAME}-live-count {
position: absolute;
right: -6px;
top: -6px;
min-width: 16px;
height: 16px;
border: 2px solid var(--rail-bg);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
color: #fff;
background: var(--badge);
font-size: 10px;
font-weight: 850;
line-height: 12px;
}
@keyframes ${NAME}-gear-turn {
0% { transform: rotate(0deg) scale(1); }
55% { transform: rotate(260deg) scale(1.06); }
100% { transform: rotate(360deg) scale(1); }
}
#${NAME}-prefs.is-dragging,
#${NAME}-prefs.is-dragging .${NAME}-pref-button {
cursor: grabbing;
}
#${NAME}-prefs.is-dragging .${NAME}-pref-button {
transform: scale(1.04);
color: var(--rail-text);
background: var(--rail-button-active);
box-shadow: 0 14px 36px rgba(0, 0, 0, .28), inset 0 0 0 1px var(--active-border);
}
#${NAME}-prefs .${NAME}-settings {
position: absolute;
top: auto;
right: 0;
bottom: 58px;
z-index: 1;
box-shadow: 0 18px 48px rgba(0, 0, 0, .3);
transform-origin: right bottom;
}
#${NAME}-prefs .${NAME}-live-panel {
position: absolute;
right: 0;
bottom: 58px;
z-index: 2;
width: min(380px, calc(100vw - 24px));
max-height: min(560px, calc(100dvh - 80px));
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: var(--bg);
box-shadow: 0 18px 48px rgba(0, 0, 0, .32);
opacity: 0;
pointer-events: none;
transform: translateY(-8px) scale(.98);
transform-origin: right bottom;
transition: opacity 150ms ease, transform 170ms cubic-bezier(.22, 1, .36, 1);
}
#${NAME}-prefs .${NAME}-live-panel.is-open {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
#${NAME}-prefs.is-panel-left .${NAME}-settings {
left: 0;
right: auto;
transform-origin: left bottom;
}
#${NAME}-prefs.is-panel-left .${NAME}-live-panel {
left: 0;
right: auto;
transform-origin: left bottom;
}
#${NAME}-prefs.is-panel-below .${NAME}-settings {
top: 58px;
bottom: auto;
transform-origin: right top;
}
#${NAME}-prefs.is-panel-below .${NAME}-live-panel {
top: 58px;
bottom: auto;
transform-origin: right top;
}
#${NAME}-prefs.is-panel-left.is-panel-below .${NAME}-settings {
transform-origin: left top;
}
#${NAME}-prefs.is-panel-left.is-panel-below .${NAME}-live-panel {
transform-origin: left top;
}
#${NAME}-prefs.is-light .${NAME}-settings {
box-shadow: 0 18px 44px rgba(24, 33, 52, .18);
}
#${NAME}-prefs.is-light .${NAME}-live-panel {
box-shadow: 0 18px 44px rgba(24, 33, 52, .18);
}
.${NAME}-compact-title {
margin-top: 10px;
}
.${NAME}-brand-eye {
position: relative;
width: 22px;
height: 14px;
border: 2px solid currentColor;
border-radius: 999px / 78%;
transform: rotate(-8deg);
}
.${NAME}-brand-eye::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
transform: translate(-50%, -50%);
}
#${NAME}-shade {
position: fixed;
inset: 0;
z-index: 100001;
background: rgba(8, 10, 18, .18);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
#${NAME}-shade.is-open {
opacity: 1;
pointer-events: auto;
}
#${NAME}-drawer-sidebar {
--bg: rgba(18, 20, 29, .98);
--panel: rgba(30, 33, 45, .96);
--panel2: rgba(39, 43, 58, .94);
--text: #eef1f7;
--muted: #a0a7ba;
--weak: #6f778b;
--line: rgba(255, 255, 255, .1);
--blue: #6385ef;
--green: #56b8aa;
--bad: #e36f86;
--rail-bg: var(--bg);
--rail-line: var(--line);
--rail-text: var(--text);
--rail-muted: var(--muted);
--rail-button: var(--panel2);
--rail-button-hover: rgba(99, 133, 239, .14);
--rail-button-active: rgba(99, 133, 239, .18);
--panel-bg: var(--panel);
--button-bg: transparent;
--button-hover: rgba(99, 133, 239, .14);
--active-bg: rgba(99, 133, 239, .16);
--active-border: rgba(99, 133, 239, .42);
--active-text: var(--text);
--soft-bg: rgba(125, 136, 158, .16);
--badge: var(--green);
position: absolute;
left: calc(-1 * var(--sidebar-rail-offset, 56px));
top: 50%;
z-index: 12;
width: var(--sidebar-rail-width, 48px);
min-height: 0;
max-height: calc(100% - 24px);
display: none;
transform: translateY(-50%);
overflow: visible;
color: var(--text);
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
#${NAME}-drawer-sidebar.is-open {
display: block;
}
#${NAME}-drawer-sidebar.is-panel-open {
width: var(--sidebar-rail-width, 48px);
}
#${NAME}-drawer-sidebar.is-light {
--bg: rgba(250, 251, 254, .98);
--panel: rgba(244, 247, 252, .98);
--panel2: rgba(255, 255, 255, .98);
--text: #202636;
--muted: #596276;
--weak: #8992a4;
--line: rgba(24, 33, 52, .12);
--soft-bg: rgba(125, 136, 158, .16);
}
.${NAME}-sidebar-rail {
min-width: 0;
width: var(--sidebar-rail-width, 48px);
max-height: min(480px, calc(min(var(--peek-height, 100dvh), 100dvh) - 92px));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 6px;
border: 1px solid var(--rail-line);
border-radius: 18px;
background: var(--rail-bg);
box-shadow: 0 14px 36px rgba(0, 0, 0, .28);
backdrop-filter: blur(14px);
position: relative;
overflow: visible;
}
.${NAME}-sidebar-tool-stack {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-height: min(360px, calc(min(var(--peek-height, 100dvh), 100dvh) - 154px));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: none;
}
.${NAME}-sidebar-tool-stack::-webkit-scrollbar {
display: none;
}
.${NAME}-sidebar-tool {
appearance: none;
position: relative;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-muted);
background: var(--rail-button);
cursor: pointer;
font: inherit;
font-weight: 850;
line-height: 1;
box-shadow: none;
transition: background 130ms ease, border-color 130ms ease, color 130ms ease, transform 130ms ease;
}
.${NAME}-sidebar-dock-scroll {
width: 30px;
height: 22px;
flex: 0 0 auto;
border-radius: 999px;
opacity: .84;
}
.${NAME}-sidebar-tool:hover {
color: var(--rail-text);
background: var(--rail-button-hover);
transform: translateX(1px) scale(1.04);
}
.${NAME}-sidebar-tool.is-active {
color: var(--rail-text);
background: var(--rail-button-active);
box-shadow: inset 0 0 0 1px var(--active-border);
}
.${NAME}-sidebar-tool:disabled {
cursor: default;
opacity: .48;
}
.${NAME}-sidebar-tool:disabled:hover {
color: var(--rail-muted);
background: var(--rail-button);
transform: none;
}
.${NAME}-sidebar-tool-icon {
position: relative;
width: 18px;
height: 18px;
display: block;
color: currentColor;
pointer-events: none;
}
.${NAME}-sidebar-tool-icon::before,
.${NAME}-sidebar-tool-icon::after {
box-sizing: border-box;
}
.${NAME}-sidebar-icon-dock-up::before,
.${NAME}-sidebar-icon-dock-down::before {
content: "";
position: absolute;
width: 9px;
height: 9px;
border-color: currentColor;
border-style: solid;
}
.${NAME}-sidebar-icon-dock-up::before {
left: 4px;
top: 6px;
border-width: 2px 0 0 2px;
transform: rotate(45deg);
}
.${NAME}-sidebar-icon-dock-down::before {
left: 4px;
top: 2px;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.${NAME}-sidebar-icon-queue::before {
content: "";
position: absolute;
left: 6px;
top: 4px;
width: 10px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 5px 0 currentColor, 0 10px 0 currentColor;
}
.${NAME}-sidebar-icon-queue::after {
content: "";
position: absolute;
left: 2px;
top: 4px;
width: 2px;
height: 2px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 5px 0 currentColor, 0 10px 0 currentColor;
}
.${NAME}-sidebar-icon-nav::before,
.${NAME}-sidebar-icon-nav::after {
content: "";
position: absolute;
border: 2px solid currentColor;
border-radius: 4px;
}
.${NAME}-sidebar-icon-nav::before {
left: 3px;
top: 6px;
width: 12px;
height: 9px;
background: transparent;
}
.${NAME}-sidebar-icon-nav::after {
left: 6px;
top: 3px;
width: 9px;
height: 7px;
border-bottom: 0;
opacity: .7;
}
.${NAME}-sidebar-icon-tools::before {
content: "";
position: absolute;
left: 4px;
top: 4px;
width: 4px;
height: 4px;
border-radius: 2px;
background: currentColor;
box-shadow: 7px 0 0 currentColor, 0 7px 0 currentColor, 7px 7px 0 currentColor;
}
.${NAME}-sidebar-icon-width::before,
.${NAME}-sidebar-icon-width::after {
content: "";
position: absolute;
left: 3px;
right: 3px;
border-color: currentColor;
border-style: solid;
}
.${NAME}-sidebar-icon-width::before {
top: 4px;
height: 10px;
border-width: 2px 0;
}
.${NAME}-sidebar-icon-width::after {
top: 8px;
height: 2px;
border-width: 0 2px;
}
.${NAME}-sidebar-icon-settings::before,
.${NAME}-sidebar-icon-reload::before,
.${NAME}-sidebar-icon-open::before,
.${NAME}-sidebar-icon-favorite::before,
.${NAME}-sidebar-icon-support::before,
.${NAME}-sidebar-icon-close::before {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Symbol", "Segoe UI", sans-serif;
font-weight: 500;
line-height: 1;
}
.${NAME}-sidebar-icon-settings::before {
content: "⚙";
font-size: 16px;
}
.${NAME}-sidebar-icon-reload::before {
content: "↻";
font-size: 18px;
}
.${NAME}-sidebar-icon-open::before {
content: "↗";
font-size: 18px;
}
.${NAME}-sidebar-icon-favorite::before {
content: "★";
font-size: 17px;
font-weight: 850;
transform: translateY(-1px);
}
.${NAME}-sidebar-icon-recent::before {
content: "";
position: absolute;
inset: 2px;
border: 2px solid currentColor;
border-radius: 999px;
}
.${NAME}-sidebar-icon-recent::after {
content: "";
position: absolute;
left: 9px;
top: 4px;
width: 5px;
height: 7px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 1px;
transform: rotate(-28deg);
transform-origin: left bottom;
}
.${NAME}-sidebar-icon-support::before {
content: "♥";
color: #fb7185;
font-size: 18px;
font-weight: 850;
transform: translateY(-1px);
}
.${NAME}-sidebar-icon-live::before {
content: "";
position: absolute;
left: 4px;
top: 4px;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-radius: 999px;
box-shadow: 0 0 0 3px rgba(86, 184, 170, .16);
}
.${NAME}-sidebar-icon-live::after {
content: "";
position: absolute;
right: 3px;
bottom: 3px;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--green);
}
.${NAME}-sidebar-icon-stats::before {
content: "";
position: absolute;
left: 5px;
bottom: 4px;
width: 3px;
height: 7px;
border-radius: 2px 2px 0 0;
background: currentColor;
box-shadow: 5px -4px 0 currentColor, 10px -8px 0 currentColor;
}
.${NAME}-sidebar-icon-stats::after {
content: "";
position: absolute;
left: 4px;
bottom: 3px;
width: 15px;
height: 13px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
border-radius: 0 0 0 3px;
opacity: .45;
}
.${NAME}-sidebar-icon-close::before {
content: "×";
font-size: 23px;
font-weight: 300;
}
.${NAME}-sidebar-tool-count {
position: absolute;
right: -6px;
top: -6px;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border: 2px solid var(--rail-bg);
border-radius: 999px;
color: #fff;
background: var(--badge);
font-size: 10px;
font-weight: 800;
line-height: 12px;
}
.${NAME}-sidebar-panel {
position: absolute;
top: 50%;
right: calc(100% + var(--sidebar-panel-gap, 8px));
z-index: 4;
width: var(--sidebar-panel-width, 240px);
min-width: var(--sidebar-panel-width, 240px);
max-height: min(420px, calc(100% - 16px));
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translate(8px, -50%);
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: var(--panel-bg);
box-shadow: 0 18px 48px rgba(0, 0, 0, .34);
transition: opacity 150ms ease, transform 170ms cubic-bezier(.22, 1, .36, 1);
}
#${NAME}-drawer-sidebar.is-panel-open .${NAME}-sidebar-panel {
opacity: 1;
pointer-events: auto;
transform: translate(0, -50%);
}
#${NAME}-drawer-sidebar.is-settings-panel .${NAME}-sidebar-panel {
width: var(--sidebar-settings-panel-width, 360px);
min-width: var(--sidebar-settings-panel-width, 360px);
height: min(560px, calc(min(var(--peek-height, 100dvh), 100dvh) - 32px));
max-height: min(560px, calc(min(var(--peek-height, 100dvh), 100dvh) - 32px));
}
#${NAME}-drawer-sidebar.is-page-nav-panel .${NAME}-sidebar-panel {
width: var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px));
min-width: var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px));
}
#${NAME}-drawer-sidebar.is-live-panel .${NAME}-sidebar-panel {
width: var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px));
min-width: var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px));
}
#${NAME}-drawer-sidebar.is-support-panel .${NAME}-sidebar-panel {
width: min(320px, var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px)));
min-width: min(320px, var(--sidebar-page-panel-width, var(--sidebar-panel-width, 280px)));
}
.${NAME}-sidebar-panel-head {
min-height: 42px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
color: var(--text);
background: var(--panel);
position: relative;
overflow: hidden;
}
.${NAME}-sidebar-panel-title {
position: relative;
z-index: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 750;
}
.${NAME}-sidebar-panel-count {
position: relative;
z-index: 1;
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
border-radius: 999px;
color: var(--text);
background: rgba(99, 133, 239, .2);
font-size: 10px;
font-weight: 850;
}
.${NAME}-sidebar-panel-head-actions {
position: relative;
z-index: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.${NAME}-sidebar-panel-head-actions .${NAME}-sidebar-panel-clear {
flex: 0 0 auto;
}
.${NAME}-sidebar-panel-icon-button {
width: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.${NAME}-sidebar-panel-icon-button::before,
.${NAME}-sidebar-panel-icon-button::after {
content: "";
position: absolute;
box-sizing: border-box;
pointer-events: none;
}
.${NAME}-sidebar-live-refresh::before {
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 999px;
transform: rotate(-28deg);
}
.${NAME}-sidebar-live-refresh::after {
right: 7px;
top: 6px;
width: 5px;
height: 5px;
border-top: 2px solid currentColor;
border-right: 2px solid currentColor;
transform: rotate(28deg);
}
.${NAME}-sidebar-live-collapse::before,
.${NAME}-sidebar-live-collapse::after {
left: 8px;
width: 12px;
height: 2px;
border-radius: 2px;
background: currentColor;
}
.${NAME}-sidebar-live-collapse::before {
top: 9px;
transform: rotate(45deg);
}
.${NAME}-sidebar-live-collapse::after {
top: 17px;
transform: rotate(-45deg);
}
.${NAME}-sidebar-panel-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 8px 8px 0;
border-bottom: 0;
background: var(--panel-bg);
}
.${NAME}-sidebar-panel-clear {
position: relative;
z-index: 1;
appearance: none;
height: 26px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 0 8px;
color: var(--muted);
background: var(--panel2);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.${NAME}-sidebar-panel-clear:hover {
color: var(--text);
border-color: rgba(99, 133, 239, .48);
background: var(--button-hover);
}
.${NAME}-queue-keep-hint {
color: var(--weak);
font-size: 10px;
line-height: 1.45;
}
.${NAME}-sidebar-queue-hint {
padding: 7px 12px 0;
background: var(--panel-bg);
}
.${NAME}-sidebar-queue-list {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-sidebar-support-list {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 10px;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.${NAME}-sidebar-support-list:hover,
.${NAME}-sidebar-support-list:focus-within {
scrollbar-color: rgba(125, 136, 158, .52) transparent;
}
.${NAME}-sidebar-support-list::-webkit-scrollbar {
width: 8px;
}
.${NAME}-sidebar-support-list::-webkit-scrollbar-track {
background: transparent;
}
.${NAME}-sidebar-support-list::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: transparent;
background-clip: padding-box;
}
.${NAME}-sidebar-support-list:hover::-webkit-scrollbar-thumb,
.${NAME}-sidebar-support-list:focus-within::-webkit-scrollbar-thumb {
background-color: rgba(125, 136, 158, .52);
}
.${NAME}-sidebar-stats-list {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-sidebar-stat-group + .${NAME}-sidebar-stat-group {
margin-top: 10px;
}
.${NAME}-sidebar-stat-group-title {
margin: 0 2px 6px;
color: var(--weak);
font-size: 10px;
font-weight: 800;
letter-spacing: 0;
}
.${NAME}-sidebar-stat-group-body {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel2);
}
.${NAME}-sidebar-stat-row {
appearance: none;
width: 100%;
min-height: 34px;
border: 0;
border-radius: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
padding: 6px 9px;
color: var(--text);
background: transparent;
font: inherit;
text-align: left;
}
.${NAME}-sidebar-stat-group-body > * + * {
border-top: 1px solid var(--line);
}
.${NAME}-sidebar-stat-copyable {
cursor: copy;
}
.${NAME}-sidebar-stat-copyable:hover {
background: var(--button-hover);
}
.${NAME}-sidebar-stat-meter {
padding: 7px 9px 8px;
}
.${NAME}-sidebar-stat-meter-head {
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.${NAME}-sidebar-stat-meter-track {
height: 5px;
margin-top: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--soft-bg);
}
.${NAME}-sidebar-stat-meter-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--badge), var(--green));
}
.${NAME}-sidebar-stat-meter-warn .${NAME}-sidebar-stat-meter-fill {
background: linear-gradient(90deg, #f97316, #ef4444);
}
.${NAME}-sidebar-stat-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--muted);
}
.${NAME}-sidebar-stat-value {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: right;
}
.${NAME}-sidebar-stat-warn {
color: #ef4444;
}
.${NAME}-support-list {
display: grid;
gap: 8px;
}
.${NAME}-support-card {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: var(--panel2);
text-decoration: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
}
.${NAME}-support-card:hover {
border-color: rgba(99, 133, 239, .5);
background: var(--button-hover);
}
.${NAME}-support-card-copy,
.${NAME}-support-card-side {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.${NAME}-support-card-side {
align-items: flex-end;
}
.${NAME}-support-card-title {
color: var(--text);
font-size: 13px;
font-weight: 850;
line-height: 1.25;
}
.${NAME}-support-card-desc {
color: var(--weak);
font-size: 11px;
font-weight: 650;
line-height: 1.35;
}
.${NAME}-support-card-amount {
min-width: 52px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 999px;
color: #fff;
background: #2563eb;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.${NAME}-support-card-open {
color: var(--muted);
font-size: 10px;
font-weight: 800;
line-height: 1.2;
}
.${NAME}-sidebar-queue-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 4px;
align-items: stretch;
}
.${NAME}-sidebar-page-row {
grid-template-columns: minmax(0, 1fr) 28px 28px 28px;
}
.${NAME}-sidebar-recent-row {
grid-template-columns: minmax(0, 1fr);
}
.${NAME}-sidebar-queue-row + .${NAME}-sidebar-queue-row {
margin-top: 4px;
}
.${NAME}-sidebar-queue-item {
appearance: none;
min-width: 0;
min-height: 36px;
border: 1px solid transparent;
border-radius: 7px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 7px;
padding: 6px 8px;
color: var(--text);
background: var(--panel2);
cursor: pointer;
font: inherit;
text-align: left;
}
.${NAME}-sidebar-queue-item:hover {
border-color: rgba(99, 133, 239, .45);
background: var(--button-hover);
}
.${NAME}-sidebar-queue-row.is-active .${NAME}-sidebar-queue-item {
border-color: rgba(78, 180, 165, .58);
background: rgba(78, 180, 165, .16);
}
.${NAME}-sidebar-queue-index {
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
background: var(--soft-bg);
font-size: 10px;
font-weight: 850;
}
.${NAME}-sidebar-queue-row.is-active .${NAME}-sidebar-queue-index {
color: #fff;
background: var(--badge);
}
.${NAME}-sidebar-queue-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 780;
}
.${NAME}-sidebar-topic-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.${NAME}-sidebar-topic-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--weak);
font-size: 10px;
font-weight: 650;
line-height: 1.2;
}
.${NAME}-sidebar-queue-row.is-active .${NAME}-sidebar-topic-meta {
color: var(--green);
}
.${NAME}-sidebar-queue-remove {
appearance: none;
width: 28px;
border: 1px solid var(--line);
border-radius: 7px;
color: var(--weak);
background: var(--panel2);
cursor: pointer;
font: inherit;
font-size: 16px;
line-height: 1;
}
.${NAME}-sidebar-queue-remove:hover {
color: var(--bad);
border-color: rgba(227, 111, 134, .45);
background: rgba(227, 111, 134, .12);
}
.${NAME}-sidebar-page-action {
appearance: none;
width: 28px;
border: 1px solid var(--line);
border-radius: 7px;
color: var(--weak);
background: var(--panel2);
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 750;
line-height: 1;
}
.${NAME}-sidebar-page-action:hover {
color: var(--text);
border-color: rgba(99, 133, 239, .45);
background: rgba(99, 133, 239, .14);
}
.${NAME}-sidebar-empty {
padding: 14px 8px;
color: var(--weak);
font-size: 12px;
text-align: center;
}
.${NAME}-sidebar-frame-tools {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-sidebar-frame-url {
min-width: 0;
margin-bottom: 6px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 7px 8px;
color: var(--muted);
background: rgba(125, 136, 158, .1);
font-size: 11px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-sidebar-frame-action {
appearance: none;
width: 100%;
min-height: 40px;
border: 1px solid transparent;
border-radius: 7px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 9px;
color: var(--text);
background: var(--panel2);
cursor: pointer;
font: inherit;
text-align: left;
}
.${NAME}-sidebar-frame-action + .${NAME}-sidebar-frame-action {
margin-top: 5px;
}
.${NAME}-sidebar-frame-action:hover {
border-color: rgba(99, 133, 239, .45);
background: var(--button-hover);
}
.${NAME}-sidebar-width-preset.is-active {
border-color: var(--active-border);
background: var(--active-bg);
}
.${NAME}-sidebar-width-preset.is-active .${NAME}-sidebar-frame-label {
color: var(--active-text);
}
.${NAME}-sidebar-frame-label,
.${NAME}-sidebar-frame-desc {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-sidebar-frame-label {
font-size: 12px;
font-weight: 820;
}
.${NAME}-sidebar-frame-desc {
color: var(--weak);
font-size: 10px;
font-weight: 750;
}
#${NAME}-drawer {
--bg: rgba(18, 20, 29, .98);
--panel: rgba(30, 33, 45, .94);
--panel2: rgba(39, 43, 58, .92);
--text: #eef1f7;
--muted: #a0a7ba;
--weak: #6f778b;
--line: rgba(255, 255, 255, .09);
--blue: #6385ef;
--green: #56b8aa;
--bad: #e36f86;
--drawer-gap: 12px;
--drawer-radius: 12px;
--drawer-left-reserve: 76px;
--sidebar-rail-width: 48px;
--sidebar-rail-offset: 56px;
--sidebar-panel-gap: 8px;
position: fixed;
top: max(var(--drawer-gap), var(--peek-top, 0px));
right: var(--drawer-gap);
z-index: 100002;
width: min(var(--peek-width, 760px), calc(100vw - var(--drawer-gap) - var(--drawer-left-reserve)));
height: min(var(--peek-height, 100dvh), calc(100dvh - var(--drawer-gap) - var(--drawer-gap)));
display: flex;
flex-direction: column;
overflow: visible;
color: var(--text);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--drawer-radius);
box-shadow: 0 22px 64px rgba(0, 0, 0, .44);
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
transform: translateX(calc(100% + var(--drawer-gap) + 12px));
transition: transform 240ms cubic-bezier(.22, 1, .36, 1);
}
#${NAME}-drawer.is-open {
transform: translateX(0);
}
#${NAME}-drawer:focus {
outline: none;
}
#${NAME}-drawer.is-light {
--bg: rgba(250, 251, 254, .98);
--panel: rgba(244, 247, 252, .96);
--panel2: rgba(255, 255, 255, .96);
--text: #202636;
--muted: #596276;
--weak: #8992a4;
--line: rgba(24, 33, 52, .11);
box-shadow: 0 22px 58px rgba(24, 33, 52, .18);
}
.${NAME}-drawer-main {
position: relative;
z-index: 1;
min-height: 0;
flex: 1 1 auto;
display: flex;
overflow: visible;
background: var(--bg);
}
.${NAME}-head {
position: relative;
z-index: 20;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 68px;
padding: 12px 14px;
color: #fff;
background: linear-gradient(135deg, #5876da 0%, #4666c4 54%, #48aa9c 100%);
border-radius: calc(var(--drawer-radius) - 1px) calc(var(--drawer-radius) - 1px) 0 0;
overflow: hidden;
}
.${NAME}-head::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, .16), transparent);
pointer-events: none;
}
.${NAME}-brand,
.${NAME}-tools {
position: relative;
z-index: 1;
}
.${NAME}-brand {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.${NAME}-brand-eye {
width: 24px;
height: 15px;
flex: 0 0 auto;
}
.${NAME}-title-block {
min-width: 0;
}
.${NAME}-title {
font-size: 15px;
line-height: 1.25;
font-weight: 850;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-subtitle {
margin-top: 2px;
color: rgba(255, 255, 255, .72);
font-size: 11px;
}
.${NAME}-tools {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 7px;
}
.${NAME}-switch {
display: flex;
align-items: center;
padding: 3px;
border-radius: 8px;
background: rgba(0, 0, 0, .18);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12);
}
.${NAME}-switch button,
.${NAME}-icon-btn,
.${NAME}-pref-button,
.${NAME}-setting,
.${NAME}-favorite-item,
.${NAME}-favorite-remove,
.${NAME}-page-nav-action,
.${NAME}-resume-button,
.${NAME}-primary-btn,
.${NAME}-soft-btn {
appearance: none;
font: inherit;
}
.${NAME}-switch button {
min-width: 46px;
height: 28px;
border: 0;
border-radius: 6px;
color: rgba(255, 255, 255, .78);
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 750;
}
.${NAME}-switch button.is-active {
color: #1f2941;
background: #fff;
}
.${NAME}-icon-btn {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(255, 255, 255, .14);
cursor: pointer;
}
.${NAME}-icon-btn:hover {
background: rgba(255, 255, 255, .24);
}
.${NAME}-icon-btn.is-favorite-active {
color: #ffe08a;
background: rgba(255, 208, 95, .22);
}
.${NAME}-drawer-footer {
position: relative;
z-index: 20;
flex: 0 0 auto;
display: none;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-top: 1px solid var(--line);
border-radius: 0 0 calc(var(--drawer-radius) - 1px) calc(var(--drawer-radius) - 1px);
color: var(--text);
background: var(--bg);
}
.${NAME}-drawer-footer.is-visible {
display: flex;
width: 100%;
}
.${NAME}-drawer-track {
min-width: 0;
width: 100%;
flex: 1 1 auto;
display: none;
flex-direction: column;
gap: 4px;
}
.${NAME}-drawer-track.is-visible {
display: flex;
}
.${NAME}-drawer-status {
--track-progress: 0%;
min-width: 0;
min-height: 24px;
width: 100%;
display: none;
align-items: center;
gap: 7px;
padding: 3px 8px;
border: 1px solid rgba(125, 136, 158, .25);
border-radius: 8px;
color: var(--muted);
background: rgba(125, 136, 158, .1);
font-size: 11px;
font-weight: 800;
line-height: 1.35;
}
.${NAME}-drawer-status.is-visible {
display: inline-flex;
}
.${NAME}-drawer-status-dot {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 3px rgba(125, 136, 158, .16);
}
.${NAME}-drawer-status-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-drawer-status.is-pending {
color: #8fb1ff;
border-color: rgba(99, 133, 239, .35);
background: rgba(99, 133, 239, .13);
}
.${NAME}-drawer-status.is-sending {
color: #a5b4fc;
border-color: rgba(129, 140, 248, .42);
background: rgba(99, 102, 241, .14);
}
.${NAME}-drawer-status.is-disabled {
color: var(--muted);
border-color: rgba(125, 136, 158, .25);
background: rgba(125, 136, 158, .1);
}
.${NAME}-drawer-status.is-sent,
.${NAME}-drawer-status.is-done {
color: var(--green);
border-color: rgba(78, 180, 165, .4);
background: rgba(78, 180, 165, .13);
}
.${NAME}-drawer-status.is-error {
color: var(--bad);
border-color: rgba(244, 63, 94, .42);
background: rgba(244, 63, 94, .13);
}
.${NAME}-drawer-track-bar {
height: 3px;
display: none;
overflow: hidden;
border-radius: 999px;
background: rgba(125, 136, 158, .18);
}
.${NAME}-drawer-track.has-progress .${NAME}-drawer-track-bar {
display: block;
}
.${NAME}-drawer-track-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--blue), var(--green));
transition: width 180ms linear;
}
.${NAME}-drawer-footer-actions {
display: none;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-left: auto;
flex: 0 0 auto;
}
.${NAME}-drawer-footer-actions.is-visible {
display: inline-flex;
}
.${NAME}-drawer-footer-btn {
appearance: none;
position: relative;
width: 28px;
height: 28px;
flex: 0 0 28px;
border: 1px solid var(--line);
border-radius: 7px;
color: var(--muted);
background: var(--panel2);
cursor: pointer;
font: inherit;
}
.${NAME}-drawer-footer-btn:hover:not(:disabled) {
color: var(--text);
border-color: rgba(99, 133, 239, .5);
background: rgba(99, 133, 239, .14);
}
.${NAME}-drawer-footer-btn:disabled {
cursor: default;
opacity: .48;
}
.${NAME}-drawer-footer-scroll {
width: auto;
min-width: 92px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 9px;
font-size: 11px;
font-weight: 850;
line-height: 1;
white-space: nowrap;
}
.${NAME}-drawer-footer-scroll.is-running,
.${NAME}-drawer-footer-scroll.is-paused {
color: var(--text);
border-color: rgba(99, 133, 239, .55);
background: rgba(99, 133, 239, .14);
}
.${NAME}-drawer-footer-scroll.is-running {
border-color: rgba(78, 180, 165, .58);
background: rgba(78, 180, 165, .16);
}
.${NAME}-drawer-footer-scroll-icon {
position: relative;
width: 14px;
height: 14px;
flex: 0 0 14px;
display: inline-block;
}
.${NAME}-drawer-footer-scroll-icon::before,
.${NAME}-drawer-footer-scroll-icon::after {
content: "";
position: absolute;
box-sizing: border-box;
}
.${NAME}-drawer-footer-scroll-icon::before {
left: 4px;
top: 2px;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 7px solid currentColor;
}
.${NAME}-drawer-footer-scroll-icon::after {
left: 2px;
top: 11px;
width: 10px;
height: 2px;
border-radius: 999px;
background: currentColor;
opacity: .42;
}
.${NAME}-drawer-footer-scroll.is-running .${NAME}-drawer-footer-scroll-icon::before,
.${NAME}-drawer-footer-scroll.is-running .${NAME}-drawer-footer-scroll-icon::after {
top: 2px;
width: 3px;
height: 10px;
border: 0;
border-radius: 2px;
background: currentColor;
opacity: 1;
}
.${NAME}-drawer-footer-scroll.is-running .${NAME}-drawer-footer-scroll-icon::before {
left: 3px;
}
.${NAME}-drawer-footer-scroll.is-running .${NAME}-drawer-footer-scroll-icon::after {
left: 8px;
}
.${NAME}-drawer-footer-scroll.is-paused .${NAME}-drawer-footer-scroll-icon::after {
background: var(--bad);
opacity: .78;
}
.${NAME}-drawer-footer-scroll-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-drawer-footer-scroll::before,
.${NAME}-drawer-footer-scroll::after {
display: none;
}
.${NAME}-drawer-footer-btn::before,
.${NAME}-drawer-footer-btn::after {
content: "";
position: absolute;
box-sizing: border-box;
pointer-events: none;
}
.${NAME}-drawer-footer-copy::before {
left: 8px;
top: 8px;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-radius: 3px;
background: transparent;
}
.${NAME}-drawer-footer-copy::after {
left: 11px;
top: 11px;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-radius: 3px;
background: var(--panel2);
}
.${NAME}-drawer-footer-open::before {
left: 8px;
top: 10px;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-top: 0;
border-right: 0;
border-radius: 2px;
}
.${NAME}-drawer-footer-open::after {
right: 8px;
top: 7px;
width: 9px;
height: 9px;
border-top: 2px solid currentColor;
border-right: 2px solid currentColor;
transform: rotate(0deg);
box-shadow: 3px -3px 0 -2px currentColor;
}
.${NAME}-settings-gear,
.${NAME}-close,
.${NAME}-favorite-star {
position: relative;
display: block;
width: 17px;
height: 17px;
}
.${NAME}-favorite-star {
width: auto;
height: auto;
font-size: 18px;
line-height: 1;
transform: translateY(-1px);
}
.${NAME}-settings-gear {
border: 2px solid currentColor;
border-radius: 50%;
box-shadow:
0 -8px 0 -6px currentColor,
0 8px 0 -6px currentColor,
8px 0 0 -6px currentColor,
-8px 0 0 -6px currentColor,
5.6px 5.6px 0 -6px currentColor,
-5.6px 5.6px 0 -6px currentColor,
5.6px -5.6px 0 -6px currentColor,
-5.6px -5.6px 0 -6px currentColor;
}
.${NAME}-settings-gear::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
border: 2px solid currentColor;
}
.${NAME}-settings-gear::after {
content: '';
position: absolute;
left: 50%;
top: -5px;
width: 2px;
height: 23px;
border-radius: 2px;
background: currentColor;
transform: translateX(-50%) rotate(45deg);
box-shadow: 0 0 0 0 currentColor;
}
.${NAME}-close::before,
.${NAME}-close::after {
content: '';
position: absolute;
left: 7px;
top: 1px;
width: 2px;
height: 14px;
border-radius: 2px;
background: currentColor;
}
.${NAME}-close::before {
transform: rotate(45deg);
}
.${NAME}-close::after {
transform: rotate(-45deg);
}
.${NAME}-settings {
position: absolute;
z-index: 30;
top: 54px;
right: 52px;
width: min(340px, calc(100vw - 24px));
max-height: min(560px, calc(100dvh - 80px));
overflow-y: auto;
overflow-x: hidden;
padding: 10px;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 8px;
color: var(--text);
background: var(--bg);
box-shadow: 0 18px 48px rgba(0, 0, 0, .32);
opacity: 0;
pointer-events: none;
transform: translateY(-8px) scale(.98);
transition: opacity 150ms ease, transform 170ms cubic-bezier(.22, 1, .36, 1);
}
.${NAME}-settings.is-open {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.${NAME}-floating-settings {
width: min(420px, calc(100vw - 24px));
overflow: hidden;
padding: 8px;
}
.${NAME}-dock-settings {
position: static;
z-index: auto;
width: 100%;
height: 100%;
max-height: none;
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
opacity: 1;
pointer-events: auto;
transform: none;
transition: none;
}
.${NAME}-dock-settings .${NAME}-prefs-head {
flex: 0 0 auto;
padding: 2px 2px 10px;
}
.${NAME}-dock-settings .${NAME}-pref-tab-strip {
flex: 0 0 auto;
}
.${NAME}-dock-settings .${NAME}-pref-tab-panel {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}
.${NAME}-dock-settings .${NAME}-favorite-list {
max-height: 150px;
}
.${NAME}-live-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 12px 8px;
border-bottom: 1px solid var(--line);
}
.${NAME}-live-title {
color: var(--text);
font-size: 14px;
font-weight: 850;
}
.${NAME}-live-actions {
display: flex;
align-items: center;
gap: 6px;
}
.${NAME}-live-icon {
appearance: none;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
background: var(--panel2);
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.${NAME}-live-icon:hover {
color: var(--text);
background: var(--button-hover);
}
.${NAME}-live-status {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
color: var(--weak);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-sidebar-live-status {
white-space: normal;
overflow: visible;
text-overflow: clip;
line-height: 1.35;
}
.${NAME}-live-list {
max-height: min(456px, calc(100dvh - 170px));
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-live-topic {
appearance: none;
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
display: grid;
gap: 4px;
padding: 9px;
color: var(--text);
background: var(--panel2);
text-align: left;
cursor: pointer;
}
.${NAME}-live-topic + .${NAME}-live-topic {
margin-top: 6px;
}
.${NAME}-live-topic:hover {
border-color: rgba(99, 133, 239, .48);
background: var(--button-hover);
}
.${NAME}-live-topic.is-unread {
box-shadow: inset 3px 0 0 var(--green);
}
.${NAME}-recent-topic.is-active {
border-color: var(--active-border);
background: var(--active-bg);
box-shadow: inset 3px 0 0 var(--blue);
}
.${NAME}-sidebar-live-topic.is-active {
border-color: var(--active-border);
background: var(--active-bg);
box-shadow: inset 3px 0 0 var(--blue);
}
.${NAME}-live-topic-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
}
.${NAME}-live-topic-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 10px;
font-weight: 650;
}
.${NAME}-live-empty {
padding: 24px 12px;
color: var(--muted);
text-align: center;
font-size: 12px;
}
.${NAME}-prefs-head {
padding: 10px 10px 12px;
border-bottom: 1px solid var(--line);
margin-bottom: 8px;
}
.${NAME}-prefs-title {
color: var(--text);
font-size: 14px;
font-weight: 850;
line-height: 1.25;
}
.${NAME}-prefs-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.${NAME}-support-header {
position: relative;
flex: 0 0 auto;
display: inline-flex;
}
.${NAME}-support-chip {
appearance: none;
flex: 0 0 auto;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
color: #fb7185;
background: var(--button);
box-shadow: none;
cursor: pointer;
font: inherit;
font-weight: 850;
line-height: 1;
transition: background 130ms ease, border-color 130ms ease, color 130ms ease, transform 130ms ease;
}
.${NAME}-support-chip:hover,
.${NAME}-support-chip[aria-expanded="true"] {
color: #f43f5e;
background: var(--button-hover);
border-color: rgba(244, 63, 94, .36);
}
.${NAME}-support-chip-icon {
font-size: 17px;
line-height: 1;
transform: translateY(-1px);
}
.${NAME}-support-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: min(268px, calc(100vw - 48px));
padding: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 16px 34px rgba(0, 0, 0, .22);
}
.${NAME}-support-popover[hidden] {
display: none;
}
.${NAME}-support-popover .${NAME}-support-card {
box-shadow: none;
}
.${NAME}-prefs-subtitle {
margin-top: 3px;
color: var(--weak);
font-size: 11px;
line-height: 1.35;
}
.${NAME}-pref-tab-strip {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) 30px;
gap: 5px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(125, 136, 158, .08);
}
.${NAME}-pref-tabs {
--ldpeek-tab-mask-start: #000 0;
--ldpeek-tab-mask-end: #000 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, var(--ldpeek-tab-mask-start), #000 18px, #000 calc(100% - 18px), var(--ldpeek-tab-mask-end));
mask-image: linear-gradient(to right, var(--ldpeek-tab-mask-start), #000 18px, #000 calc(100% - 18px), var(--ldpeek-tab-mask-end));
}
.${NAME}-pref-tab-strip.is-scroll-left .${NAME}-pref-tabs {
--ldpeek-tab-mask-start: transparent 0;
}
.${NAME}-pref-tab-strip.is-scroll-right .${NAME}-pref-tabs {
--ldpeek-tab-mask-end: transparent 100%;
}
.${NAME}-pref-tabs::-webkit-scrollbar {
display: none;
}
.${NAME}-pref-tab-scroll {
appearance: none;
width: 30px;
height: 32px;
border: 1px solid var(--line);
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
background: var(--panel2);
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
transition: color .16s ease, border-color .16s ease, background .16s ease, opacity .16s ease;
}
.${NAME}-pref-tab-icon {
width: 18px;
height: 18px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
transition: transform .16s ease;
}
.${NAME}-pref-tab-scroll:hover:not(:disabled) {
color: var(--text);
border-color: rgba(99, 133, 239, .48);
background: rgba(99, 133, 239, .14);
}
.${NAME}-pref-tab-scroll.is-left:hover:not(:disabled) .${NAME}-pref-tab-icon {
transform: translateX(-1px);
}
.${NAME}-pref-tab-scroll.is-right:hover:not(:disabled) .${NAME}-pref-tab-icon {
transform: translateX(1px);
}
.${NAME}-pref-tab-scroll:disabled {
cursor: default;
opacity: .36;
}
.${NAME}-pref-tab {
appearance: none;
flex: 0 0 auto;
min-width: 66px;
height: 32px;
border: 1px solid transparent;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 8px;
color: var(--muted);
background: transparent;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 850;
}
.${NAME}-pref-tab:hover {
color: var(--text);
background: rgba(125, 136, 158, .14);
}
.${NAME}-pref-tab.is-active {
color: var(--text);
border-color: rgba(99, 133, 239, .46);
background: rgba(99, 133, 239, .18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
}
.${NAME}-pref-tab-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-pref-tab-count {
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 999px;
color: var(--text);
background: rgba(99, 133, 239, .22);
font-size: 10px;
line-height: 1;
}
.${NAME}-pref-tab-panel {
display: none;
max-height: min(430px, calc(100dvh - 220px));
overflow-y: auto;
overflow-x: hidden;
margin-top: 8px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(125, 136, 158, .07);
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-pref-tab-panel.is-active {
display: block;
}
.${NAME}-setting-group {
padding-top: 8px;
border-top: 1px solid rgba(125, 136, 158, .16);
}
.${NAME}-setting-group:first-child {
padding-top: 0;
border-top: 0;
}
.${NAME}-setting-group + .${NAME}-setting-group {
margin-top: 10px;
}
.${NAME}-setting-group-title {
min-height: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 6px;
color: var(--muted);
font-size: 11px;
font-weight: 850;
}
.${NAME}-section-count {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
border-radius: 999px;
color: var(--text);
background: rgba(99, 133, 239, .18);
font-size: 10px;
font-weight: 850;
}
.${NAME}-floating-settings .${NAME}-favorite-list {
max-height: 150px;
}
.${NAME}-floating-settings .${NAME}-queue-keep-hint {
margin: -2px 0 7px;
}
.${NAME}-floating-settings .${NAME}-resume-button {
margin-bottom: 0;
}
.${NAME}-list-search {
width: 100%;
height: 34px;
margin-bottom: 7px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 9px;
color: var(--text);
background: var(--panel2);
font: inherit;
font-size: 12px;
outline: none;
}
.${NAME}-list-search:focus {
border-color: rgba(99, 133, 239, .58);
box-shadow: 0 0 0 3px rgba(99, 133, 239, .14);
}
.${NAME}-memory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.${NAME}-memory-row {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel2);
}
.${NAME}-memory-label {
color: var(--weak);
font-size: 10px;
font-weight: 800;
line-height: 1.2;
}
.${NAME}-memory-value {
min-width: 0;
color: var(--text);
font-size: 12px;
font-weight: 850;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-memory-parts {
display: grid;
gap: 5px;
margin-top: 7px;
}
.${NAME}-memory-part {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--weak);
font-size: 10px;
line-height: 1.35;
}
.${NAME}-memory-part-label,
.${NAME}-memory-part-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-memory-part-value {
flex: 0 0 auto;
color: var(--muted);
}
.${NAME}-keyword-field {
display: block;
padding: 8px;
border-radius: 8px;
background: var(--panel2);
}
.${NAME}-keyword-field + .${NAME}-keyword-field {
margin-top: 8px;
}
.${NAME}-keyword-label,
.${NAME}-keyword-desc {
display: block;
}
.${NAME}-keyword-label {
color: var(--text);
font-size: 12px;
font-weight: 800;
}
.${NAME}-keyword-desc {
margin-top: 1px;
color: var(--weak);
font-size: 10px;
}
.${NAME}-keyword-input {
width: 100%;
min-height: 72px;
margin-top: 7px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 8px;
color: var(--text);
background: var(--bg);
font: inherit;
font-size: 12px;
line-height: 1.45;
outline: none;
}
.${NAME}-keyword-input:focus {
border-color: rgba(99, 133, 239, .58);
box-shadow: 0 0 0 3px rgba(99, 133, 239, .14);
}
.${NAME}-category-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel2);
max-height: 180px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-category-picker-empty {
color: var(--weak);
font-size: 11px;
line-height: 1.45;
}
.${NAME}-category-chip {
appearance: none;
position: relative;
max-width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
background: var(--bg);
cursor: pointer;
font: inherit;
line-height: 1.2;
}
.${NAME}-category-chip:hover,
.${NAME}-category-chip.is-active {
color: var(--text);
border-color: rgba(99, 133, 239, .48);
background: var(--button-hover);
}
.${NAME}-category-chip.is-active {
padding-left: 22px;
color: var(--active-text);
border-color: rgba(99, 133, 239, .78);
background: linear-gradient(135deg, rgba(99, 133, 239, .28), rgba(86, 184, 170, .18));
box-shadow:
inset 0 0 0 1px rgba(99, 133, 239, .48),
0 0 0 2px rgba(99, 133, 239, .12);
}
.${NAME}-category-chip.is-active::before {
content: "";
position: absolute;
left: 8px;
top: 50%;
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 0 2px rgba(86, 184, 170, .22);
transform: translateY(-50%);
}
.${NAME}-category-chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 750;
}
.${NAME}-category-chip-slug {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--weak);
font-size: 10px;
font-weight: 650;
}
.${NAME}-category-chip.is-active .${NAME}-category-chip-name {
font-weight: 850;
}
.${NAME}-category-chip.is-active .${NAME}-category-chip-slug {
color: var(--text);
}
.${NAME}-settings-title {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.${NAME}-size-title {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.${NAME}-setting {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--text);
background: var(--panel2);
cursor: pointer;
text-align: left;
}
.${NAME}-setting + .${NAME}-setting {
margin-top: 6px;
}
.${NAME}-setting.is-active {
border-color: rgba(99, 133, 239, .65);
background: rgba(99, 133, 239, .16);
}
.${NAME}-setting-copy {
min-width: 0;
display: flex;
flex-direction: column;
}
.${NAME}-setting-label {
font-size: 12px;
font-weight: 800;
}
.${NAME}-setting-desc {
margin-top: 1px;
color: var(--weak);
font-size: 10px;
}
.${NAME}-setting-dot {
width: 16px;
height: 16px;
border: 2px solid var(--weak);
border-radius: 50%;
flex: 0 0 auto;
}
.${NAME}-setting.is-active .${NAME}-setting-dot {
border-color: var(--blue);
background: radial-gradient(circle at center, var(--blue) 0 45%, transparent 48%);
}
.${NAME}-favorite-list {
max-height: 220px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .5) transparent;
}
.${NAME}-favorite-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
align-items: stretch;
gap: 6px;
}
.${NAME}-page-nav-row {
grid-template-columns: minmax(0, 1fr) 28px 28px 28px;
}
.${NAME}-page-nav-row.is-active .${NAME}-page-nav-item {
border-color: rgba(78, 180, 165, .58);
background: rgba(78, 180, 165, .16);
}
.${NAME}-page-nav-row.is-active .${NAME}-favorite-meta {
color: var(--green);
}
.${NAME}-favorite-row + .${NAME}-favorite-row {
margin-top: 6px;
}
.${NAME}-recent-item + .${NAME}-recent-item {
margin-top: 6px;
}
.${NAME}-favorite-item,
.${NAME}-resume-button {
min-width: 0;
min-height: 38px;
border: 1px solid transparent;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 7px 9px;
color: var(--text);
background: var(--panel2);
cursor: pointer;
text-align: left;
}
.${NAME}-resume-button {
width: 100%;
margin-bottom: 10px;
}
.${NAME}-favorite-item:hover,
.${NAME}-resume-button:hover {
border-color: rgba(99, 133, 239, .45);
background: rgba(99, 133, 239, .14);
}
.${NAME}-resume-button:disabled {
cursor: default;
opacity: .58;
}
.${NAME}-resume-button:disabled:hover {
border-color: transparent;
background: var(--panel2);
}
.${NAME}-favorite-title,
.${NAME}-favorite-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-favorite-title {
font-size: 12px;
font-weight: 800;
}
.${NAME}-favorite-meta {
margin-top: 1px;
color: var(--weak);
font-size: 10px;
}
.${NAME}-favorite-remove {
width: 28px;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--weak);
background: var(--panel2);
cursor: pointer;
font-size: 18px;
font-weight: 500;
line-height: 1;
}
.${NAME}-favorite-remove:hover {
color: var(--bad, #e36f86);
border-color: rgba(227, 111, 134, .45);
background: rgba(227, 111, 134, .12);
}
.${NAME}-page-nav-action {
width: 28px;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--weak);
background: var(--panel2);
cursor: pointer;
font-size: 14px;
font-weight: 750;
line-height: 1;
}
.${NAME}-page-nav-action:hover {
color: var(--text);
border-color: rgba(99, 133, 239, .45);
background: rgba(99, 133, 239, .14);
}
.${NAME}-favorite-empty {
padding: 10px;
border-radius: 8px;
color: var(--weak);
background: var(--panel2);
font-size: 12px;
text-align: center;
}
.${NAME}-resume-box:empty {
display: none;
}
.${NAME}-management-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin-top: 8px;
}
.${NAME}-management-actions .${NAME}-soft-btn {
min-height: 30px;
padding: 5px 7px;
font-size: 11px;
white-space: nowrap;
}
.${NAME}-queue-actions {
grid-template-columns: minmax(0, 1fr);
}
.${NAME}-size-row {
display: grid;
grid-template-columns: 34px 24px minmax(56px, 1fr) 24px 64px 20px;
align-items: center;
gap: 6px;
min-height: 40px;
padding: 6px;
border-radius: 8px;
background: var(--panel2);
}
.${NAME}-size-row + .${NAME}-size-row {
margin-top: 6px;
}
.${NAME}-size-hint {
margin: -2px 0 8px;
padding: 7px 9px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--muted);
background: rgba(125, 136, 158, .08);
font-size: 11px;
line-height: 1.35;
}
.${NAME}-size-label,
.${NAME}-size-unit {
color: var(--muted);
font-size: 11px;
font-weight: 750;
}
.${NAME}-size-step,
.${NAME}-size-clicker,
.${NAME}-tuning-step,
.${NAME}-tuning-number {
appearance: none;
font: inherit;
}
.${NAME}-size-step {
width: 24px;
height: 24px;
border: 1px solid var(--line);
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text);
background: var(--bg);
cursor: pointer;
font-size: 15px;
font-weight: 850;
line-height: 1;
}
.${NAME}-size-step:hover {
border-color: rgba(99, 133, 239, .55);
background: rgba(99, 133, 239, .14);
}
.${NAME}-size-clicker {
--size-ratio: 50%;
position: relative;
width: 100%;
height: 24px;
border: 0;
border-radius: 8px;
overflow: hidden;
background:
linear-gradient(90deg, rgba(99, 133, 239, .86) 0 var(--size-ratio), rgba(125, 136, 158, .24) var(--size-ratio) 100%);
box-shadow: inset 0 0 0 1px var(--line);
cursor: pointer;
text-align: left;
}
.${NAME}-size-clicker::before {
content: '';
position: absolute;
inset: 9px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, .38);
opacity: .5;
}
.${NAME}-size-thumb {
position: absolute;
left: var(--size-ratio);
top: 50%;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, .95);
border-radius: 50%;
background: linear-gradient(135deg, var(--blue), var(--green));
box-shadow: 0 2px 7px rgba(0, 0, 0, .25);
transform: translate(-50%, -50%);
pointer-events: none;
}
.${NAME}-size-number {
width: 64px;
height: 24px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 3px 6px;
color: var(--text);
background: var(--bg);
font: inherit;
font-size: 12px;
}
.${NAME}-tuning-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px 58px 24px 28px;
align-items: center;
gap: 6px;
min-height: 46px;
padding: 7px;
border-radius: 8px;
background: var(--panel2);
}
.${NAME}-tuning-row + .${NAME}-tuning-row {
margin-top: 6px;
}
.${NAME}-tuning-copy {
min-width: 0;
display: flex;
flex-direction: column;
}
.${NAME}-tuning-label {
color: var(--text);
font-size: 12px;
font-weight: 800;
line-height: 1.25;
}
.${NAME}-tuning-help {
margin-top: 1px;
color: var(--weak);
font-size: 10px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-tuning-step {
width: 24px;
height: 24px;
border: 1px solid var(--line);
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text);
background: var(--bg);
cursor: pointer;
font-size: 15px;
font-weight: 850;
line-height: 1;
}
.${NAME}-tuning-step:hover {
border-color: rgba(99, 133, 239, .55);
background: rgba(99, 133, 239, .14);
}
.${NAME}-tuning-number {
width: 58px;
height: 24px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 3px 5px;
color: var(--text);
background: var(--bg);
font-size: 12px;
}
.${NAME}-tuning-unit {
min-width: 0;
color: var(--muted);
font-size: 10px;
font-weight: 750;
white-space: nowrap;
}
.${NAME}-read-badge,
.${NAME}-effective-badge,
.${NAME}-last-viewed-badge,
.${NAME}-keyword-badge,
.${NAME}-date-badge,
.${NAME}-similar-badge {
display: inline-flex;
align-items: center;
min-height: 18px;
margin-left: 8px;
padding: 1px 7px;
border: 1px solid rgba(99, 133, 239, .35);
border-radius: 999px;
color: #4f6fd6;
background: rgba(99, 133, 239, .1);
font-size: 11px;
font-weight: 750;
line-height: 1.2;
vertical-align: middle;
white-space: nowrap;
}
.${NAME}-effective-badge {
border-color: rgba(78, 180, 165, .42);
color: #348b7f;
background: rgba(78, 180, 165, .12);
}
.${NAME}-last-viewed-badge {
border-color: #1d4ed8;
color: #fff;
background: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, .18);
}
.${NAME}-keyword-badge {
border-color: rgba(212, 168, 83, .55);
color: #8f6a11;
background: rgba(255, 207, 92, .2);
}
.${NAME}-date-badge {
border-color: rgba(125, 136, 158, .34);
color: #667085;
background: rgba(125, 136, 158, .1);
font-weight: 700;
}
.${NAME}-similar-badge {
border-color: rgba(99, 133, 239, .38);
color: #4f6fd6;
background: rgba(99, 133, 239, .12);
font-weight: 800;
}
.${NAME}-category-marked {
position: relative;
}
tr.${NAME}-category-marked,
.topic-list-item.${NAME}-category-marked,
.latest-topic-list-item.${NAME}-category-marked,
.search-result-topic.${NAME}-category-marked,
li.${NAME}-category-marked,
article.${NAME}-category-marked {
box-shadow: inset 3px 0 0 var(--ldpeek-category-color, #6385ef);
}
.${NAME}-keyword-blocked {
display: none !important;
}
a.${NAME}-keyword-highlight {
color: #a66f00 !important;
text-decoration: underline;
text-decoration-color: rgba(166, 111, 0, .45);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.${NAME}-last-viewed-row {
position: relative;
border-radius: 8px;
box-shadow: inset 4px 0 0 #2563eb;
animation: ${NAME}-last-viewed-pulse 2200ms ease-out 1;
}
@keyframes ${NAME}-last-viewed-pulse {
0% {
box-shadow: inset 4px 0 0 #2563eb, 0 0 0 0 rgba(37, 99, 235, .58);
background: rgba(37, 99, 235, .18);
}
55% {
box-shadow: inset 4px 0 0 #2563eb, 0 0 0 8px rgba(37, 99, 235, .1);
background: rgba(37, 99, 235, .1);
}
100% {
box-shadow: inset 4px 0 0 #2563eb, 0 0 0 12px rgba(37, 99, 235, 0);
background: transparent;
}
}
.${NAME}-body {
position: relative;
z-index: 1;
min-height: 0;
min-width: 0;
flex: 1 1 auto;
overflow: auto;
overscroll-behavior: contain;
padding: 10px;
scrollbar-width: thin;
scrollbar-color: rgba(125, 136, 158, .55) transparent;
}
.${NAME}-body::-webkit-scrollbar {
width: 8px;
}
.${NAME}-body::-webkit-scrollbar-thumb {
border-radius: 8px;
background: rgba(125, 136, 158, .55);
}
.${NAME}-state {
min-height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 18px;
color: var(--muted);
text-align: center;
}
.${NAME}-error {
color: var(--bad);
}
.${NAME}-state-message {
max-width: 520px;
}
.${NAME}-state-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.${NAME}-spinner {
width: 24px;
height: 24px;
margin-right: 10px;
border: 3px solid rgba(125, 136, 158, .28);
border-top-color: var(--blue);
border-radius: 50%;
animation: ${NAME}-spin 800ms linear infinite;
}
@keyframes ${NAME}-spin {
to { transform: rotate(360deg); }
}
.${NAME}-summary {
display: flex;
flex-direction: column;
gap: 14px;
}
.${NAME}-summary-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.${NAME}-summary-tab {
min-width: 0;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 8px;
color: var(--muted);
background: var(--panel);
font: inherit;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.${NAME}-summary-tab:hover {
border-color: rgba(99, 133, 239, .42);
color: var(--text);
background: var(--button-hover);
}
.${NAME}-summary-tab.is-active {
border-color: rgba(99, 133, 239, .62);
color: var(--text);
background: rgba(99, 133, 239, .16);
}
.${NAME}-summary-tab-label,
.${NAME}-summary-tab-badge {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.${NAME}-summary-tab-badge {
flex: 0 0 auto;
max-width: 64px;
color: var(--weak);
font-size: 10px;
}
.${NAME}-summary-hint {
margin-top: -8px;
color: var(--weak);
font-size: 11px;
line-height: 1.45;
}
.${NAME}-summary-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px 32px 32px auto;
gap: 6px;
align-items: center;
}
.${NAME}-summary-search-input {
min-width: 0;
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
color: var(--text);
background: var(--panel);
font: inherit;
font-size: 12px;
outline: none;
}
.${NAME}-summary-search-input:focus {
border-color: rgba(99, 133, 239, .58);
box-shadow: 0 0 0 3px rgba(99, 133, 239, .14);
}
.${NAME}-summary-search-btn {
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
background: var(--panel);
font: inherit;
font-size: 18px;
font-weight: 850;
line-height: 1;
cursor: pointer;
}
.${NAME}-summary-search-btn:hover {
color: var(--text);
background: var(--button-hover);
}
.${NAME}-summary-search-btn:disabled {
opacity: .45;
cursor: default;
background: var(--panel);
}
.${NAME}-summary-search-count {
min-width: 34px;
color: var(--weak);
font-size: 11px;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
.${NAME}-summary-search-hit {
border-radius: 3px;
padding: 0 2px;
color: inherit;
background: rgba(250, 204, 21, .38);
}
.${NAME}-summary-search-hit.is-current {
background: rgba(249, 115, 22, .52);
box-shadow: 0 0 0 1px rgba(249, 115, 22, .42);
}
.${NAME}-summary-view {
display: flex;
flex-direction: column;
gap: 14px;
}
.${NAME}-summary-view[hidden] {
display: none;
}
.${NAME}-summary-empty {
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
color: var(--muted);
background: var(--panel);
text-align: center;
font-size: 13px;
font-weight: 750;
}
.${NAME}-topic-head,
.${NAME}-cooked,
.${NAME}-frame-shell {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.${NAME}-topic-head {
padding: 14px;
}
.${NAME}-author {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.${NAME}-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text);
background: var(--panel2);
font-weight: 850;
}
.${NAME}-author-copy {
min-width: 0;
}
.${NAME}-name {
font-weight: 850;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-username,
.${NAME}-meta {
color: var(--muted);
font-size: 12px;
}
.${NAME}-topic-title {
margin: 12px 0 0;
color: var(--text);
font-size: 18px;
line-height: 1.35;
font-weight: 850;
word-break: break-word;
}
.${NAME}-meta {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.${NAME}-cooked {
padding: 16px;
word-break: break-word;
background: var(--panel2);
}
.${NAME}-cooked p {
margin: 0 0 10px;
}
.${NAME}-cooked p:last-child {
margin-bottom: 0;
}
.${NAME}-cooked a {
color: var(--blue);
text-decoration: none;
}
.${NAME}-cooked a:hover {
text-decoration: underline;
}
.${NAME}-cooked blockquote {
margin: 10px 0;
padding: 10px 12px;
border-left: 4px solid var(--blue);
border-radius: 0 8px 8px 0;
background: rgba(99, 133, 239, .1);
}
.${NAME}-cooked ul,
.${NAME}-cooked ol {
margin: 8px 0;
padding-left: 24px;
}
.${NAME}-cooked li {
margin: 4px 0;
}
.${NAME}-cooked pre {
max-height: 260px;
overflow: auto;
margin: 10px 0;
padding: 12px;
border-radius: 8px;
background: rgba(5, 8, 14, .36);
}
#${NAME}-drawer.is-light .${NAME}-cooked pre {
background: rgba(24, 33, 52, .06);
}
.${NAME}-cooked code {
font-size: 13px;
}
.${NAME}-cooked h1,
.${NAME}-cooked h2,
.${NAME}-cooked h3 {
margin: 14px 0 8px;
font-size: 1.15em;
line-height: 1.35;
}
.${NAME}-cooked img,
.${NAME}-cooked video {
display: block;
max-width: 100%;
height: auto;
margin: 10px 0;
border-radius: 8px;
}
.${NAME}-cooked table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.${NAME}-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.${NAME}-primary-btn,
.${NAME}-soft-btn {
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 12px;
border-radius: 8px;
cursor: pointer;
font-weight: 750;
text-decoration: none;
}
.${NAME}-primary-btn {
border: 0;
color: #fff;
background: linear-gradient(135deg, var(--blue), var(--green));
}
.${NAME}-soft-btn {
border: 1px solid var(--line);
color: var(--text);
background: var(--panel2);
}
.${NAME}-frame-shell {
position: relative;
height: 100%;
min-height: 420px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.${NAME}-frame-top {
position: relative;
z-index: 3;
min-height: 40px;
display: flex;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
overflow: visible;
}
.${NAME}-frame-url-form {
--${NAME}-frame-control-height: 32px;
min-width: 0;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.${NAME}-frame-mode {
flex: 0 0 auto;
height: var(--${NAME}-frame-control-height);
display: inline-flex;
align-items: center;
white-space: nowrap;
font-weight: 750;
line-height: 1;
}
.${NAME}-frame-navs {
position: relative;
height: var(--${NAME}-frame-control-height);
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.${NAME}-frame-nav {
appearance: none;
box-sizing: border-box;
width: var(--${NAME}-frame-control-height);
height: var(--${NAME}-frame-control-height);
margin: 0;
border: 1px solid var(--line);
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--text);
background: var(--panel2);
cursor: pointer;
font: inherit;
font-size: 0;
font-weight: 850;
line-height: 1;
}
.${NAME}-frame-nav::before {
display: block;
color: currentColor;
font-size: 20px;
font-weight: 850;
line-height: 1;
transform: translateY(-1px);
}
.${NAME}-frame-nav-back::before {
content: "‹";
}
.${NAME}-frame-nav-forward::before {
content: "›";
}
.${NAME}-frame-nav:hover:not(:disabled) {
border-color: rgba(99, 133, 239, .55);
background: rgba(99, 133, 239, .14);
}
.${NAME}-frame-nav:disabled {
cursor: default;
opacity: .38;
}
.${NAME}-frame-history-menu {
position: absolute;
z-index: 8;
top: 42px;
left: 10px;
width: min(340px, calc(100% - 20px));
max-height: 280px;
overflow: auto;
padding: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
box-shadow: 0 14px 36px rgba(0, 0, 0, .26);
opacity: 0;
pointer-events: none;
transform: translateY(-4px) scale(.98);
transform-origin: top left;
transition: opacity 120ms ease, transform 140ms ease;
}
.${NAME}-frame-history-menu.is-open {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.${NAME}-frame-history-menu.is-forward {
transform-origin: top right;
}
.${NAME}-frame-history-item {
appearance: none;
width: 100%;
min-height: 32px;
border: 0;
border-radius: 6px;
padding: 6px 8px;
display: block;
color: var(--text);
background: transparent;
cursor: pointer;
font: inherit;
font-size: 12px;
line-height: 1.35;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${NAME}-frame-history-item:hover {
background: rgba(99, 133, 239, .14);
color: var(--accent);
}
.${NAME}-frame-url-input {
box-sizing: border-box;
min-width: 0;
flex: 1 1 120px;
width: 100%;
height: var(--${NAME}-frame-control-height);
margin: 0 !important;
margin-block: 0 !important;
border: 1px solid var(--line);
border-radius: 7px;
padding: 0 9px;
color: var(--text);
background: var(--panel2);
font: inherit;
font-size: 12px;
line-height: normal;
}
#${NAME}-drawer .${NAME}-frame-url-input {
margin: 0 !important;
margin-block: 0 !important;
margin-bottom: 0 !important;
align-self: center;
}
.${NAME}-frame-url-input:focus {
outline: none;
border-color: rgba(99, 133, 239, .65);
box-shadow: 0 0 0 3px rgba(99, 133, 239, .16);
}
@media (max-width: 560px) {
.${NAME}-frame-mode {
display: none;
}
.${NAME}-frame-url-form {
gap: 6px;
}
}
.${NAME}-frame-wrap {
position: relative;
z-index: 1;
min-height: 0;
flex: 1 1 auto;
background: #fff;
}
.${NAME}-frame-status {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
color: #667085;
background: rgba(255, 255, 255, .92);
pointer-events: none;
}
.${NAME}-frame-status.has-actions {
pointer-events: auto;
}
.${NAME}-frame-status-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.${NAME}-frame {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
min-height: calc(min(var(--peek-height, 100dvh), 100dvh) - 176px);
border: 0;
display: block;
background: #fff;
}
#${NAME}-toast {
position: fixed;
left: 50%;
bottom: 28px;
z-index: 100004;
max-width: min(360px, calc(100vw - 32px));
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, .18);
border-radius: 8px;
color: #fff;
background: rgba(31, 41, 55, .94);
box-shadow: 0 14px 34px rgba(20, 28, 48, .28);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 10px);
transition: opacity 160ms ease, transform 180ms cubic-bezier(.22, 1, .36, 1);
}
#${NAME}-toast.is-open {
opacity: 1;
transform: translate(-50%, 0);
}
@media (max-width: 768px) {
#${NAME}-prefs {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
#${NAME}-prefs .${NAME}-pref-button,
#${NAME}-prefs .${NAME}-launcher-symbol,
#${NAME}-prefs .${NAME}-launcher-dot,
#${NAME}-prefs .${NAME}-pie-menu::before,
#${NAME}-prefs .${NAME}-pie-menu::after,
#${NAME}-prefs .${NAME}-pie-item,
#${NAME}-prefs .${NAME}-pie-item::before,
#${NAME}-prefs .${NAME}-pie-item::after,
#${NAME}-prefs .${NAME}-pie-item-icon {
transition: none;
}
.${NAME}-pie-item:hover .${NAME}-pie-settings-icon,
.${NAME}-pie-item:focus-visible .${NAME}-pie-settings-icon,
.${NAME}-pie-item.is-active .${NAME}-pie-settings-icon {
animation: none;
}
}
`;
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
} else {
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
}
function showToast(message) {
if (!document.body) {
console.info(`${LOG_PREFIX} ${message}`);
return;
}
let toast = document.getElementById(`${NAME}-toast`);
if (!toast) {
toast = Dom.make('div', { id: `${NAME}-toast`, role: 'status', 'aria-live': 'polite' });
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('is-open');
clearTimeout(toast.hideTimer);
toast.hideTimer = window.setTimeout(() => {
toast.classList.remove('is-open');
}, 1800);
}
function copyText(text, message = '已复制') {
const value = String(text || '');
if (!value) return;
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(value)
.then(() => showToast(message))
.catch(() => {
window.prompt('复制下面的内容', value);
});
return;
}
window.prompt('复制下面的内容', value);
}
function exportLocalData() {
const payload = {
app: APP_NAME,
version: '0.4.74',
exportedAt: new Date().toISOString(),
prefs: Prefs.value,
readMemory: ReadMemory.items,
favorites: Favorites.items,
readLaterQueue: ReadLaterQueue.items,
pageNav: PageNav.items,
drawerState: DrawerState.value
};
copyText(JSON.stringify(payload, null, 2), '配置已复制');
}
function importLocalData() {
const raw = window.prompt('粘贴 LD Peek 导出的配置 JSON');
if (!raw) return;
try {
const payload = JSON.parse(raw);
if (payload.prefs) Prefs.save(payload.prefs);
if (Array.isArray(payload.readMemory)) {
ReadMemory.items = payload.readMemory.map((item) => ReadMemory.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.memoryLimit);
ReadMemory.save();
}
if (Array.isArray(payload.favorites)) {
Favorites.items = payload.favorites.map((item) => Favorites.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.favoriteLimit);
Favorites.save();
}
if (Array.isArray(payload.readLaterQueue)) {
ReadLaterQueue.items = payload.readLaterQueue.map((item) => ReadLaterQueue.normalizeItem(item)).filter(Boolean).slice(0, MAX_READ_LATER);
ReadLaterQueue.save();
}
if (Array.isArray(payload.pageNav)) {
PageNav.items = payload.pageNav.map((item) => PageNav.normalizeItem(item)).filter(Boolean).slice(0, Prefs.value.pageNavLimit);
PageNav.save();
}
if (payload.drawerState) {
DrawerState.save(payload.drawerState);
}
Drawer.applySize();
Drawer.syncControls();
FloatingPrefs.applyPosition();
FloatingPrefs.applyTheme();
TopicBadges.refresh();
FloatingPrefs.sync();
showToast('配置已导入');
} catch (error) {
console.warn(`${LOG_PREFIX} 配置导入失败`, error);
showToast('导入失败,请检查 JSON');
}
}
function notifyModeChange(mode) {
const label = MODES[Prefs.mode(mode)].label;
const message = `已切换到:${label}模式`;
if (typeof GM_notification === 'function') {
try {
GM_notification({
title: APP_NAME,
text: message,
timeout: 1800,
silent: true
});
return;
} catch (_) {
// 部分脚本管理器可能声明了接口但禁用通知权限,失败时回退到页面提示。
}
}
showToast(message);
}
// 油猴菜单命令作为轻量备用入口,方便偏好使用扩展菜单的用户。
function registerMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand(`切换 ${APP_NAME} 默认模式`, () => {
const prefs = Prefs.toggle();
FloatingPrefs.sync();
if (Drawer.topicId && Drawer.root?.classList.contains('is-open')) {
Drawer.open(Drawer.topicId, prefs.mode, LastViewedMarker.anchor, Drawer.sourceHref, { trackSource: Drawer.trackViewSource });
}
notifyModeChange(prefs.mode);
});
GM_registerMenuCommand(`打开 ${APP_NAME} 实时新帖`, () => {
LiveTopics.open();
});
}
Prefs.load();
if (CreatedOrder.apply()) return;
CreatedOrder.start();
ReadMemory.load();
Favorites.load();
ReadLaterQueue.load();
PageNav.load();
DrawerState.load();
CrossTabSync.start();
Dom.ready(() => {
installStyle();
MiniEye.mount();
FloatingPrefs.mount();
PageNav.start();
TopicBadges.start();
Interactions.bind();
registerMenu();
});
})();