A lightweight script developed specifically for the web version of Threads, offering features such as one-click copying of thread links (with no tracking code), a one-click thread content copy button, absolute time display, image carousel animation, and more, making the browsing experience smoother! All settings are now integrated into a floating button. (Fixed precompute cache inaccuracy)
// ==UserScript==
// @name Threads Plus
// @name:en Threads Plus
// @name:zh-TW Threads Plus
// @name:ja Threads Plus
// @name:ko Threads Plus
// @namespace https://greasyfork.org/zh-TW/users/1620480-shana2023
// @version 1.3.3
// @description A lightweight script developed specifically for the web version of Threads, offering features such as one-click copying of thread links (with no tracking code), a one-click thread content copy button, absolute time display, image carousel animation, and more, making the browsing experience smoother! All settings are now integrated into a floating button. (Fixed precompute cache inaccuracy)
// @description:en A lightweight script developed specifically for the web version of Threads, offering features such as one-click copying of thread links (with no tracking code), a one-click thread content copy button, absolute time display, image carousel animation, and more, making the browsing experience smoother! All settings are now integrated into a floating button. (Fixed precompute cache inaccuracy)
// @description:zh-TW 專為 Threads 網頁版開發的輕量型腳本,提供一鍵複製串文連結(無追蹤碼)、一鍵複製串文內容按鈕、絕對時間顯示、圖片輪播動畫等功能,讓瀏覽體驗更流暢!所有設定現已整合至懸浮按鈕(位於右上角,對齊發表按鈕)。 (修正預計算快取不準確問題)
// @description:ja Threads ウェブ版向けに開発された軽量スクリプト。ワンクリックでスレッドのリンクをコピー(トラッキングコードなし)、ワンクリックでスレッドの内容をコピーするボタン、絶対時刻表示、画像スライドアニメーションなどの機能を提供し、ブラウジング体験をよりスムーズにします!全ての設定は右上のフローティングボタンに統合されました。 (プリコンピュートキャッシュの不正確さを修正)
// @description:ko Threads 웹 버전을 위해 개발된 경량 스크립트로, 원클릭 스레드 링크 복사(추적 코드 제외), 원클릭 스레드 내용 복사 버튼, 절대 시간 표시, 이미지 캐러셀 애니메이션 등의 기능을 제공하여 더 부드러운 브라우징 경험을 선사합니다! 모든 설정이 오른쪽 상단의 플로팅 버튼에 통합되었습니다. (프리컴퓨트 캐시 부정확성 수정)
// @author Shana2023
// @match https://www.threads.com/*
// @match https://threads.com/*
// @match https://www.threads.net/*
// @match https://threads.net/*
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_getValue
// @grant GM_setValue
// @license GPL-3.0-or-later
// @run-at document-start
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIHJ4PSIyNCIgZmlsbD0iIzAwMDAwMCIvPjx0ZXh0IHg9IjY0IiB5PSI4OCIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjZmZmZmZmIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5APC90ZXh0Pjwvc3ZnPg==
// ==/UserScript==
(function () {
'use strict';
// ════════════════════════ 核心層 ════════════════════════
class Logger {
static #PREFIX = '[TP]';
static info(msg, ...args) { console.log(Logger.#PREFIX, msg, ...args); }
static warn(msg, ...args) { console.warn(Logger.#PREFIX, msg, ...args); }
static error(msg, ...args) { console.error(Logger.#PREFIX, msg, ...args); }
}
class EventBus {
#handlers = {};
on(event, fn) { (this.#handlers[event] ??= []).push(fn); }
off(event, fn) {
const list = this.#handlers[event];
if (!list) return;
this.#handlers[event] = list.filter(f => f !== fn);
}
emit(event, ...args) { (this.#handlers[event] || []).forEach(fn => fn(...args)); }
}
class I18n {
static #DICT = {
copyLinkTitle: { zh:'複製這則串文連結(無追蹤碼)', en:'Copy thread link (tracking-free)', ja:'スレッドのリンクをコピー(追跡コードなし)', ko:'스레드 링크 복사 (추적 코드 없음)' },
copyTextTitle: { zh:'複製這則串文內容', en:'Copy thread text', ja:'スレッドの内容をコピー', ko:'스레드 내용 복사' },
cleanLinkMenu: { zh:'複製連結(無追蹤碼)', en:'Copy link (tracking-free)', ja:'リンクをコピー(追跡コードなし)', ko:'링크 복사 (추적 코드 없음)' },
settingsEnableLink: { zh:'複製串文連結(無追蹤碼)', en:'Copy link (tracking-free)', ja:'スレッドのリンクをコピー(追跡コードなし)', ko:'스레드 링크 복사 (추적 코드 없음)' },
settingsEnableCopy: { zh:'複製串文內容', en:'Copy thread text', ja:'スレッドの内容をコピー', ko:'스레드 내용 복사' },
settingsEnableTime: { zh:'顯示絕對時間', en:'Show absolute time', ja:'絶対時刻を表示', ko:'절대 시간 표시' },
settingsCarouselAnim:{ zh:'圖片輪播動畫', en:'Image carousel animation', ja:'画像カルーセルアニメーション', ko:'이미지 캐러셀 애니메이션' },
settingsReset: { zh:'還原預設設定', en:'Reset default settings', ja:'デフォルト設定に戻す', ko:'기본 설정으로 초기화' },
animSlide: { zh:'平滑', en:'Slide', ja:'スライド', ko:'슬라이드' },
animFade: { zh:'淡入', en:'Fade', ja:'フェード', ko:'페이드' },
prev: { zh:'上一張', en:'Previous', ja:'前へ', ko:'이전' },
next: { zh:'下一張', en:'Next', ja:'次へ', ko:'다음' },
};
static #LANG = (() => {
const raw = (navigator.language || 'en').toLowerCase();
if (raw.startsWith('zh')) return 'zh';
if (raw.startsWith('ja')) return 'ja';
if (raw.startsWith('ko')) return 'ko';
return 'en';
})();
static getLanguage() { return I18n.#LANG; }
static t(key) {
const entry = I18n.#DICT[key];
if (!entry) return key;
return entry[I18n.#LANG] || entry.en || key;
}
}
class Config {
static #KEY = 'threads-plus-settings';
static #DEFAULT = {
enableTextCopy: true,
enableLinkCopy: true,
carouselEffect: 'slide',
enableAbsoluteTime: true,
};
static #data = { ...Config.#DEFAULT };
static _eventBus = null;
static load() {
try { const raw = typeof GM_getValue === 'function' ? GM_getValue(Config.#KEY, null) : null; if (raw) Object.assign(Config.#data, JSON.parse(raw)); } catch (e) { Logger.warn('Settings load failed', e); }
Config.#data = { ...Config.#DEFAULT, ...Config.#data };
}
static save() {
try { if (typeof GM_setValue === 'function') GM_setValue(Config.#KEY, JSON.stringify(Config.#data)); } catch (e) { Logger.warn('Settings save failed', e); }
}
static get(key) { return Config.#data[key]; }
static set(key, value) {
if (Config.#data[key] === value) return;
Config.#data[key] = value;
Config.save();
Config._eventBus?.emit('setting-changed', { key, value });
}
static reset() {
Config.#data = { ...Config.#DEFAULT };
Config.save();
Config._eventBus?.emit('settings-reset');
}
}
class Utils {
static SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
static copyToClipboard(text) {
try { if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {}); } catch { /* ignore */ }
}
static blockEvent(e) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); }
static formatAbsoluteTime(isoString) {
try {
const d = new Date(isoString); if (isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch { return ''; }
}
}
class DomService {
static isVisibleRect(r) { return r.width > 0 && r.height > 0; }
static rectDistanceScore(src, cand) {
if (!cand || cand.width === 0 || cand.height === 0) return Number.MAX_SAFE_INTEGER;
return Math.abs((src.top + src.height/2) - (cand.top + cand.height/2)) * 3 + Math.abs((src.left + src.width/2) - (cand.left + cand.width/2));
}
static isShareSvg(svg) { const label = svg.getAttribute('aria-label') || svg.querySelector?.('title')?.textContent || ''; return /分享|share|共有|공유/i.test(label); }
static isCompactIconRect(r) { return r && r.width >= 18 && r.height >= 18 && r.width <= 92 && r.height <= 58; }
static countShareIconsInNode(node) { return Array.from(node.querySelectorAll?.('svg[aria-label]') || []).filter(DomService.isShareSvg).length; }
static isInsideNestedPostBlock(el, root) { const press = el?.closest?.('[data-pressable-container]'); return !!(press && press !== root && root?.contains?.(press) && DomService.countShareIconsInNode(press) > 0); }
static findClickableAncestor(node, boundary) { let cur = node; for (let d = 0; cur && cur !== boundary && d < 8; d++) { if (cur.matches?.('[role="button"],button,a,[tabindex="0"]')) return cur; if (getComputedStyle(cur).cursor === 'pointer') return cur; cur = cur.parentElement; } return node.parentElement; }
static findShareIconSlot(svg, boundary) { let cur = svg, best = DomService.findClickableAncestor(svg, boundary) || svg.parentElement; for (let d = 0; cur && cur !== boundary && d < 8; d++) { const p = cur.parentElement; if (!p) break; if (!DomService.isCompactIconRect(p.getBoundingClientRect())) break; best = p; cur = p; } return best; }
static findPostBlockRoot(shareButton) { const article = shareButton.closest?.('article,[role="article"]'); if (article && DomService.countShareIconsInNode(article) === 1) return article; let press = shareButton.parentElement; for (let d = 0; press && d < 16; d++, press = press.parentElement) { if (!press.hasAttribute?.('data-pressable-container')) continue; const r = press.getBoundingClientRect(); const hasId = DomService.getPostIdsInNode(press).size > 0 || !!press.querySelector('time[datetime]'); if (r.width >= 220 && r.height >= 48 && r.width <= Math.min(innerWidth, 1100) && DomService.countShareIconsInNode(press) >= 1 && hasId) return press; } let node = shareButton.parentElement, best = null; for (let d = 0; node && d < 16; d++, node = node.parentElement) { if (node === document.body || node === document.documentElement) break; const r = node.getBoundingClientRect(); if (r.width < 220 || r.height < 48 || r.width > Math.min(innerWidth, 1100)) continue; const sc = DomService.countShareIconsInNode(node); if (sc > 1) break; if (sc !== 1) continue; const ids = DomService.getPostIdsInNode(node); if (ids.size !== 1 && !node.querySelector('time[datetime]')) continue; best = node; } return best; }
static sanitizeFilename(v) { const c = String(v || 'unknown').replace(/^@/,'').replace(/[\\/:*?"<>|]+/g,'_').replace(/\s+/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,''); return c || 'unknown'; }
static parsePostInfoFromUrl(url) { try { const u = new URL(url, location.href); const m = u.pathname.match(/\/@([^/]+)\/post\/([^/?#]+)/); if (!m) return null; return { author:DomService.sanitizeFilename(decodeURIComponent(m[1])), postId:DomService.sanitizeFilename(decodeURIComponent(m[2])), postUrl:u.href }; } catch { return null; } }
static buildCleanUrl(info) { if (!info?.author || !info?.postId) return ''; return `https://www.threads.com/@${info.author}/post/${info.postId}`; }
static getCurrentDetailPostInfo() { return DomService.parsePostInfoFromUrl(location.href); }
static getPostIdsInNode(node) { const ids = new Set(); const links = [...(node.matches?.('a[href*="/post/"]')?[node]:[]), ...Array.from(node.querySelectorAll?.('a[href*="/post/"]')||[])]; links.forEach(l => { const info = DomService.parsePostInfoFromUrl(l.href || l.getAttribute?.('href')); if (info?.postId) ids.add(info.postId); }); return ids; }
/** 尋找文章內的操作欄元素 (通常是包含分享按鈕的容器) */
static findActionBarForPost(post) {
const svgs = post.querySelectorAll('svg[aria-label]');
for (const svg of svgs) {
if (DomService.isShareSvg(svg)) {
const slot = DomService.findShareIconSlot(svg, post);
if (slot) return slot.parentElement; // 回傳 actionBar
}
}
return null;
}
}
class TextService {
static #MAX_CACHE = 200;
static #cache = new Map();
static #cacheGet(key) { if (!key.isConnected) { TextService.#cache.delete(key); return undefined; } if (!TextService.#cache.has(key)) return undefined; const val = TextService.#cache.get(key); TextService.#cache.delete(key); TextService.#cache.set(key, val); return val; }
static #cacheSet(key, value) { for (const k of TextService.#cache.keys()) { if (!k.isConnected) TextService.#cache.delete(k); } if (TextService.#cache.size >= TextService.#MAX_CACHE) { const oldest = TextService.#cache.keys().next().value; if (oldest) TextService.#cache.delete(oldest); } TextService.#cache.set(key, value); }
/** 預先計算可見文章的文字,使用正確的 actionBar 以確保快取準確 */
static precomputeVisiblePosts() {
const posts = document.querySelectorAll('article, [role="article"]');
for (const post of posts) {
const rect = post.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) continue; // 不可見,跳過
try {
const actionBar = DomService.findActionBarForPost(post); // 嘗試取得操作欄
if (!actionBar) continue; // 找不到操作欄時跳過,避免快取不乾淨的結果
TextService.extract(post, actionBar);
} catch (e) { /* 忽略預計算中的錯誤 */ }
}
}
static #getRenderedText(el) { return el ? String(el.innerText || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '') : ''; }
static #stripCarouselCounter(text) { let t = String(text || ''); t = t.replace(/\n[ \t\u00a0]*\d+[ \t\u00a0]*\n[ \t\u00a0]*\/[ \t\u00a0]*\n[ \t\u00a0]*\d+[ \t\u00a0]*$/, ''); t = t.replace(/\n[ \t\u00a0]*\d+[ \t\u00a0]*\/[ \t\u00a0]*\d+[ \t\u00a0]*$/, ''); return t.replace(/[ \t\u00a0]+$/g, '').replace(/\n+$/g, ''); }
static cleanFragment(t) { return String(t || '').replace(/\r\n?/g, '\n').replace(/[ \t\u00a0]*(?:\n[ \t\u00a0]*)?(?:翻譯|查看翻譯|翻訳|翻訳を見る|번역|번역 보기)[ \t\u00a0]*$/i, '').replace(/[ \t\u00a0]+$/g, '').replace(/^\n+|\n+$/g, ''); }
static findBestPostInfo(node, element, excludeNested = false) { if (node.matches?.('a[href*="/post/"]')) { const own = DomService.parsePostInfoFromUrl(node.href); if (own) return own; } const links = Array.from(node.querySelectorAll?.('a[href*="/post/"]') || []).filter(l => !excludeNested || !DomService.isInsideNestedPostBlock(l, node)); const srcRect = element.getBoundingClientRect(); const candidates = links.map(l => ({ link:l, info:DomService.parsePostInfoFromUrl(l.href) })).filter(i => i.info).map(i => ({ ...i, score: i.link.contains(element) ? -1 : DomService.rectDistanceScore(srcRect, i.link.getBoundingClientRect()) })).sort((a, b) => a.score - b.score); return candidates[0]?.info || null; }
static findPostInfo(node) { return node.matches?.('a[href*="/post/"]') ? DomService.parsePostInfoFromUrl(node.href) : Array.from(node.querySelectorAll?.('a[href*="/post/"]') || []).map(l => DomService.parsePostInfoFromUrl(l.href)).find(Boolean) || null; }
static #computeBoundaryTop(root, actionBar) { const rr = root.getBoundingClientRect(); const at = actionBar?.getBoundingClientRect?.().top; const validAt = (at != null && at > rr.top + 4) ? at : undefined; const mt = Array.from(root.querySelectorAll('img, video')).map(e => e.getBoundingClientRect()).filter(r => r.width >= 96 && r.height >= 96).map(r => r.top).filter(t => t >= rr.top).sort((a, b) => a - b)[0]; return Math.min(Number.isFinite(mt) ? mt : Infinity, validAt !== undefined ? validAt : Infinity, rr.bottom); }
static #makeExclusionChecker(root, boundaryTop, postInfo) { return function(el) { if (!el || !root.contains(el)) return true; if (el.closest('.tp-copy-tool-button, .tp-link-tool-button, #tp-toast, #tp-lightbox')) return true; const ia = el.closest('button, [role="button"], nav'); if (ia && ia !== root && root.contains(ia)) return true; const link = el.closest('a[href]'); if (link && (link === el || link.contains(el))) { const href = link.getAttribute('href') || ''; if (/\/post\//i.test(href)) { const li = DomService.parsePostInfoFromUrl(href); if (!(postInfo?.postId && li?.postId === postInfo.postId)) return true; } else if (/\/@[^/]+\/?$|\/search(?:\?|$)/i.test(href)) return true; } if (el.querySelector('time, img, video')) return true; const r = el.getBoundingClientRect(); if (!DomService.isVisibleRect(r) || r.top >= boundaryTop || r.bottom <= root.getBoundingClientRect().top) return true; const text = TextService.#getRenderedText(el); if (!text) return true; if (postInfo?.author && text.replace(/^@/, '') === postInfo.author.replace(/^@/, '')) return true; if (/^\d[\d,.]*\s*$/.test(text)) return true; if (/^\d+\s*(秒|分鐘?|分|小時|天|週|周|個月|月|年|秒|分|時間|日|週間|ヶ月|年|초|분|시간|일|주|개월|년)\s*$/.test(text)) return true; return false; }; }
static #scoreElement(el, root, boundaryTop) { const text = TextService.#getRenderedText(el); const r = el.getBoundingClientRect(), rr = root.getBoundingClientRect(); const lines = text.split('\n').length; const ws = getComputedStyle(el).whiteSpace || ''; const hasNested = !!el.querySelector('button, [role="button"], img, video, time'); let score = text.length * 8 + Math.min(lines, 20) * 30; score += el.matches('[dir="auto"]') ? 420 : 0; score += /pre|break-spaces/.test(ws) ? 220 : 0; score += r.width >= 180 ? 80 : 0; score += Math.min(160, Math.max(0, r.top - rr.top) * 0.45); if (hasNested) score -= 900; if (r.bottom > boundaryTop + 4) score -= 500; if (text.length <= 2) score -= 80; return score; }
static extract(root, actionBar) { if (!root) return ''; const cached = TextService.#cacheGet(root); if (cached !== undefined) return cached; const boundaryTop = TextService.#computeBoundaryTop(root, actionBar); const postInfo = TextService.findBestPostInfo(root, actionBar || root, true) || TextService.findPostInfo(root); const isExcluded = TextService.#makeExclusionChecker(root, boundaryTop, postInfo); const MAX = 500; const collect = (els) => { if (els.length > MAX) els = els.slice(0, MAX); return els.filter(el => !isExcluded(el)).map(el => ({ el, text: TextService.cleanFragment(TextService.#getRenderedText(el)), rect: el.getBoundingClientRect(), score: TextService.#scoreElement(el, root, boundaryTop) })).filter(i => i.text).sort((a, b) => b.score - a.score); }; let candidates = collect(Array.from(root.querySelectorAll('[dir="auto"]'))); if (!candidates.length) candidates = collect(Array.from(root.querySelectorAll('p, div, span'))); const ordered = candidates.filter(i => !candidates.some(o => o !== i && i.el.contains(o.el) && o.text === i.text)).sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left); const fragments = []; ordered.forEach(i => { if (!i.text) return; if (fragments.some(f => f === i.text || f.includes(i.text))) return; for (let idx = fragments.length - 1; idx >= 0; idx--) { if (i.text.includes(fragments[idx])) fragments.splice(idx, 1); } fragments.push(i.text); }); const result = TextService.#stripCarouselCounter(fragments.join('\n')); TextService.#cacheSet(root, result); return result; }
}
class StyleManager {
static #INJECTED = false;
static inject() { if (StyleManager.#INJECTED) return; StyleManager.#INJECTED = true; const css = StyleManager.#buildCSS(); if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const style = document.createElement('style'); style.id = 'tp-styles'; style.textContent = css; document.documentElement.appendChild(style); } }
static #buildCSS() {
const btn = `.tp-button-wrapper{display:inline-flex!important;align-items:center!important;gap:2px!important;vertical-align:middle!important}.tp-copy-tool-button,.tp-link-tool-button{width:clamp(32px,8vw,40px)!important;height:clamp(28px,7vw,36px)!important;min-width:clamp(32px,8vw,40px)!important;flex:0 0 auto!important;border:0!important;border-radius:999px!important;padding:0!important;color:rgb(228,230,235)!important;background:transparent!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;cursor:pointer!important;pointer-events:auto!important;margin:0!important;line-height:1!important;transition:background .15s!important}.tp-copy-tool-button:hover,.tp-link-tool-button:hover{background:rgba(255,255,255,0.08)!important}.tp-btn-active{background:rgba(0,122,255,0.25)!important}.tp-copy-tool-button svg,.tp-link-tool-button svg{width:clamp(16px,4vw,20px)!important;height:clamp(16px,4vw,20px)!important;display:block!important;fill:none!important;stroke:currentColor!important;stroke-width:1.75!important;stroke-linecap:round!important;stroke-linejoin:round!important;transform:translateY(0.5px)!important;pointer-events:none!important}`;
const lightbox = `#tp-lightbox{position:fixed!important;inset:0!important;z-index:2147483646!important;background:rgba(0,0,0,0.92)!important;display:flex!important;align-items:center!important;justify-content:center!important;user-select:none!important;-webkit-user-select:none!important;transition:opacity .25s ease!important}#tp-lightbox[hidden]{display:none!important;opacity:0!important}#tp-lightbox:not([hidden]){opacity:1!important}#tp-lightbox .tp-lb-track{display:flex!important;transition:transform .7s cubic-bezier(.22,.61,.36,1)!important;will-change:transform!important;height:100%!important;align-items:center!important}#tp-lightbox .tp-lb-slide{flex:0 0 100%!important;display:flex!important;align-items:center!important;justify-content:center!important;height:100%!important;position:relative!important;overflow:hidden!important;transition:transform .7s cubic-bezier(.22,.61,.36,1),opacity .7s ease,filter .7s ease!important;transform:scale(.8)!important;opacity:.3!important;filter:blur(4px)!important}#tp-lightbox .tp-lb-slide.tp-active{transform:scale(1)!important;opacity:1!important;filter:blur(0)!important}#tp-lightbox .tp-lb-track.tp-lb-fade{position:relative!important;display:block!important;transition:none!important}#tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide{position:absolute!important;inset:0!important;transition:opacity 1s ease-in-out!important;opacity:0!important;transform:none!important;filter:none!important;z-index:0!important;pointer-events:none!important;will-change:opacity}#tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide.tp-active{opacity:1!important;z-index:1!important;pointer-events:auto!important}#tp-lightbox .tp-lb-slide img{max-width:92vw!important;max-height:85vh!important;object-fit:contain!important;border-radius:4px!important;opacity:0;transform:scale(1.02);filter:blur(4px);transition:opacity .5s cubic-bezier(.4,0,.2,1),transform .5s cubic-bezier(.4,0,.2,1),filter .5s cubic-bezier(.4,0,.2,1);will-change:opacity,transform,filter}#tp-lightbox .tp-lb-slide img.tp-loaded{opacity:1;transform:scale(1);filter:blur(0px)}#tp-lightbox .tp-lb-spinner{position:absolute!important;width:28px!important;height:28px!important;border:2.5px solid rgba(255,255,255,0.25)!important;border-top-color:#fff!important;border-radius:50%!important;animation:tp-spin .7s linear infinite!important;transition:opacity .3s ease!important;opacity:1}#tp-lightbox .tp-lb-spinner.tp-spinner-fade{opacity:0!important}@keyframes tp-spin{to{transform:rotate(360deg)}}#tp-lightbox .tp-lb-nav{position:absolute!important;top:50%!important;transform:translateY(-50%)!important;width:40px!important;height:40px!important;border:0!important;background:rgba(128,128,128,0.45)!important;backdrop-filter:blur(12px)!important;-webkit-backdrop-filter:blur(12px)!important;border-radius:50%!important;color:#fff!important;cursor:pointer!important;display:flex!important;align-items:center!important;justify-content:center!important;z-index:10!important;padding:0!important}#tp-lightbox .tp-lb-nav:hover{background:rgba(160,160,160,0.55)!important}#tp-lightbox .tp-lb-nav svg{width:20px!important;height:20px!important;stroke:white!important;stroke-width:2.5!important;stroke-linecap:round!important;stroke-linejoin:round!important;fill:none!important}#tp-lightbox .tp-lb-prev{left:16px!important}#tp-lightbox .tp-lb-next{right:16px!important}#tp-lightbox .tp-lb-counter{position:absolute!important;bottom:16px!important;left:50%!important;transform:translateX(-50%)!important;color:#aaa!important;font-size:13px!important;background:rgba(0,0,0,0.45)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;padding:4px 12px!important;border-radius:20px!important}`;
return btn + lightbox;
}
}
// ════════════════════════ 應用層 ════════════════════════
class BaseModule { name = 'unnamed'; bus; constructor(name, bus) { this.name = name; this.bus = bus; } init() {} refresh() {} destroy() {} }
class AppKernel {
#modules = []; #bus; #observer = null; #observerTarget = null; #hasForcedRefresh = false; #lastRefreshTime = 0; #refreshTimer = null; #scrollHandler = null; #resizeHandler = null;
#precomputeTimer = null;
static #THROTTLE_MS = 200;
constructor(bus) { this.#bus = bus; }
register(ModuleClass) { const instance = new ModuleClass(this.#bus); if (typeof instance.init !== 'function') { Logger.warn('模組缺少 init 方法,已跳過', instance.name); return; } this.#modules.push(instance); }
start() { Config._eventBus = this.#bus; Config.load(); this.#bus.on('refresh-all', () => this.refreshAll()); this.#modules.forEach(mod => { try { mod.init(); } catch (e) { Logger.warn(`模組初始化失敗:${mod.name}`, e); } }); this.#updateObserverTarget(); this.#bindGlobalEvents(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.#forceRefreshAll()); } else { this.#forceRefreshAll(); } Logger.info('已啟動'); }
stop() { if (this.#refreshTimer !== null) { clearTimeout(this.#refreshTimer); this.#refreshTimer = null; } if (this.#observer) { this.#observer.disconnect(); this.#observer = null; } this.#observerTarget = null; if (this.#scrollHandler) { document.removeEventListener('scroll', this.#scrollHandler, { capture: true }); this.#scrollHandler = null; } if (this.#resizeHandler) { window.removeEventListener('resize', this.#resizeHandler, { capture: true }); this.#resizeHandler = null; } if (this.#precomputeTimer) { clearTimeout(this.#precomputeTimer); this.#precomputeTimer = null; } this.#modules.forEach(mod => { try { if (typeof mod.destroy === 'function') mod.destroy(); } catch (e) { Logger.warn(`模組 destroy 失敗:${mod.name}`, e); } }); this.#hasForcedRefresh = false; this.#lastRefreshTime = 0; Logger.info('已停止'); }
refreshAll() { this.#doRefresh(); }
#doRefresh() { this.#lastRefreshTime = Date.now(); this.#modules.forEach(mod => { try { if (typeof mod.refresh === 'function') mod.refresh(); } catch (e) { Logger.warn(`refresh 失敗:${mod.name}`, e); } }); this.#scheduleIdlePrecompute(); }
#scheduleRefresh() { this.#updateObserverTarget(); if (this.#refreshTimer !== null) { clearTimeout(this.#refreshTimer); this.#refreshTimer = null; } const elapsed = Date.now() - this.#lastRefreshTime; if (elapsed >= AppKernel.#THROTTLE_MS) { this.#doRefresh(); } else { this.#refreshTimer = setTimeout(() => { this.#refreshTimer = null; this.#doRefresh(); }, AppKernel.#THROTTLE_MS - elapsed); } }
#resolveBestTarget() { return document.querySelector('[role="main"]') || document.body; }
#updateObserverTarget() { const newTarget = this.#resolveBestTarget(); if (newTarget === this.#observerTarget) return; if (this.#observer) { this.#observer.disconnect(); this.#observer = null; } this.#observerTarget = newTarget; if (!this.#observerTarget) return; this.#observer = new MutationObserver(mutations => { const needs = mutations.some(m => { if (m.type === 'attributes') return m.attributeName === 'src' || m.attributeName === 'srcset'; const changed = [...Array.from(m.addedNodes || []), ...Array.from(m.removedNodes || [])]; return changed.some(n => { const el = n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement; if (!el) return false; return el.matches?.('article, [role="article"]') || el.querySelector?.('svg'); }); }); if (needs) this.#scheduleRefresh(); }); this.#observer.observe(this.#observerTarget, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset'] }); }
#bindGlobalEvents() { this.#scrollHandler = () => this.#scheduleRefresh(); this.#resizeHandler = () => this.#scheduleRefresh(); document.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true }); window.addEventListener('resize', this.#resizeHandler, { passive: true, capture: true }); }
#forceRefreshAll() { if (this.#hasForcedRefresh) return; this.#hasForcedRefresh = true; const schedule = fn => requestIdleCallback ? requestIdleCallback(fn, { timeout: 100 }) : setTimeout(fn, 50); schedule(() => this.#doRefresh()); }
#scheduleIdlePrecompute() {
if (this.#precomputeTimer) clearTimeout(this.#precomputeTimer);
this.#precomputeTimer = setTimeout(() => {
this.#precomputeTimer = null;
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => TextService.precomputeVisiblePosts(), { timeout: 200 });
} else {
TextService.precomputeVisiblePosts();
}
}, 500);
}
}
// ════════════════════════ 模組層 ════════════════════════
class CopyButtonsModule extends BaseModule {
#copyBtnByRoot = new WeakMap(); #linkBtnByRoot = new WeakMap(); #copyBtnByShare = new WeakMap(); #linkBtnByShare = new WeakMap();
#copyCtx = new WeakMap(); #linkCtx = new WeakMap(); #liveCopyBtns = new Set(); #liveLinkBtns = new Set(); #liveWrappers = new Set();
#lastLinkCopy = null; #lastTextCopy = null;
constructor(bus) { super('copy-buttons', bus); }
init() { this.bus.on('setting-changed', ({ key }) => { if (key === 'enableLinkCopy' || key === 'enableTextCopy') this.refresh(); }); this.bus.on('settings-reset', () => this.refresh()); }
refresh() { if (!document.body) return; const linkEnabled = Config.get('enableLinkCopy'), copyEnabled = Config.get('enableTextCopy'); if (this.#lastLinkCopy !== linkEnabled) { if (!linkEnabled) { this.#liveLinkBtns.forEach(b => b.remove()); this.#liveLinkBtns.clear(); this.#linkBtnByRoot = new WeakMap(); this.#linkBtnByShare = new WeakMap(); this.#linkCtx = new WeakMap(); } this.#lastLinkCopy = linkEnabled; } if (this.#lastTextCopy !== copyEnabled) { if (!copyEnabled) { this.#liveCopyBtns.forEach(b => b.remove()); this.#liveCopyBtns.clear(); this.#copyBtnByRoot = new WeakMap(); this.#copyBtnByShare = new WeakMap(); this.#copyCtx = new WeakMap(); } this.#lastTextCopy = copyEnabled; } this.#scanAndInsert(); this.#handleDetailPage(); }
#scanAndInsert() { const activeCopy = new Set(), activeLink = new Set(), activeWrappers = new Set(), seenRoots = new Set(), seenSlots = new Set(); document.querySelectorAll('svg[aria-label]').forEach(svg => { if (!DomService.isShareSvg(svg)) return; const shareBtn = DomService.findShareIconSlot(svg, document.body); if (!shareBtn || seenSlots.has(shareBtn)) return; seenSlots.add(shareBtn); if (!DomService.isCompactIconRect(shareBtn.getBoundingClientRect())) return; const root = this.#resolveRoot(shareBtn); if (!root || seenRoots.has(root)) return; seenRoots.add(root); let wrapper = shareBtn.parentElement?.querySelector?.(':scope > .tp-button-wrapper'); if (!wrapper) { wrapper = document.createElement('span'); wrapper.className = 'tp-button-wrapper'; shareBtn.after(wrapper); } activeWrappers.add(wrapper); if (Config.get('enableLinkCopy')) { const b = this.#getOrCreateLinkBtn(root, shareBtn); if (!wrapper.contains(b)) wrapper.appendChild(b); activeLink.add(b); } if (Config.get('enableTextCopy')) { const b = this.#getOrCreateCopyBtn(root, shareBtn); if (!wrapper.contains(b)) wrapper.appendChild(b); activeCopy.add(b); } const first = wrapper.querySelector('.tp-link-tool-button'), second = wrapper.querySelector('.tp-copy-tool-button'); if (first && second && first.nextElementSibling !== second) wrapper.insertBefore(first, second); }); this.#liveCopyBtns.forEach(b => { if (!activeCopy.has(b)) { b.remove(); this.#liveCopyBtns.delete(b); } }); this.#liveLinkBtns.forEach(b => { if (!activeLink.has(b)) { b.remove(); this.#liveLinkBtns.delete(b); } }); this.#liveWrappers.forEach(w => { if (!activeWrappers.has(w)) w.remove(); this.#liveWrappers.delete(w); }); activeWrappers.forEach(w => this.#liveWrappers.add(w)); }
#resolveRoot(shareBtn) { const copyBtn = this.#copyBtnByShare.get(shareBtn); if (copyBtn) { const ctx = this.#copyCtx.get(copyBtn); if (ctx?.root?.isConnected) return ctx.root; } return DomService.findPostBlockRoot(shareBtn); }
#feedback(btn, html) { if (btn.dataset.tpFeedback) return; btn.dataset.tpFeedback = '1'; btn.innerHTML = Utils.SVG_CHECK; btn.classList.add('tp-btn-active'); setTimeout(() => { btn.innerHTML = html; btn.classList.remove('tp-btn-active'); delete btn.dataset.tpFeedback; }, 600); }
#getOrCreateLinkBtn(root, shareBtn) { let btn = this.#linkBtnByShare.get(shareBtn) || this.#linkBtnByRoot.get(root); const html = '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="8" y="8" width="13" height="13" rx="2"></rect><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"></path></svg>'; if (!btn || !btn.isConnected) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tp-link-tool-button'; btn.title = I18n.t('copyLinkTitle'); btn.setAttribute('aria-label', I18n.t('copyLinkTitle')); btn.innerHTML = html; btn.addEventListener('click', e => { Utils.blockEvent(e); this.#feedback(btn, html); const ctx = this.#linkCtx.get(btn); const info = ctx?.root ? (TextService.findBestPostInfo(ctx.root, ctx.shareButton, true) || TextService.findPostInfo(ctx.root)) : DomService.parsePostInfoFromUrl(location.href); const url = DomService.buildCleanUrl(info || DomService.parsePostInfoFromUrl(location.href)); if (!url) return; Utils.copyToClipboard(url); }, true); this.#linkBtnByRoot.set(root, btn); this.#linkBtnByShare.set(shareBtn, btn); this.#linkCtx.set(btn, { root, shareButton: shareBtn }); this.#liveLinkBtns.add(btn); } else { this.#linkBtnByRoot.set(root, btn); this.#linkBtnByShare.set(shareBtn, btn); this.#linkCtx.set(btn, { root, shareButton: shareBtn }); } return btn; }
#getOrCreateCopyBtn(root, shareBtn) { let btn = this.#copyBtnByShare.get(shareBtn) || this.#copyBtnByRoot.get(root); const html = '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"></rect><path d="M8 8h8M8 12h8M8 16h8"></path></svg>'; if (!btn || !btn.isConnected) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tp-copy-tool-button'; btn.title = I18n.t('copyTextTitle'); btn.setAttribute('aria-label', I18n.t('copyTextTitle')); btn.innerHTML = html; btn.addEventListener('click', e => { Utils.blockEvent(e); this.#feedback(btn, html); const ctx = this.#copyCtx.get(btn); const text = TextService.extract(ctx.root, ctx.actionBar); if (!text) return; Utils.copyToClipboard(text); }, true); this.#copyBtnByRoot.set(root, btn); this.#copyBtnByShare.set(shareBtn, btn); this.#copyCtx.set(btn, { root, shareButton: shareBtn, actionBar: shareBtn.parentElement }); this.#liveCopyBtns.add(btn); } else { this.#copyBtnByRoot.set(root, btn); this.#copyBtnByShare.set(shareBtn, btn); this.#copyCtx.set(btn, { root, shareButton: shareBtn, actionBar: shareBtn.parentElement }); } return btn; }
#handleDetailPage() { if (!DomService.getCurrentDetailPostInfo()) return; const root = this.#findDetailRoot(); if (!root) return; const shareBtn = this.#findDetailShareBtn(root); if (!shareBtn) return; let wrapper = shareBtn.parentElement?.querySelector?.(':scope > .tp-button-wrapper'); if (!wrapper) { wrapper = document.createElement('span'); wrapper.className = 'tp-button-wrapper'; shareBtn.after(wrapper); } if (Config.get('enableLinkCopy')) { const b = this.#linkBtnByRoot.get(root) || this.#linkBtnByShare.get(shareBtn); if (b && !wrapper.contains(b)) wrapper.appendChild(b); } if (Config.get('enableTextCopy')) { const b = this.#copyBtnByRoot.get(root) || this.#copyBtnByShare.get(shareBtn); if (b && !wrapper.contains(b)) wrapper.appendChild(b); } const first = wrapper.querySelector('.tp-link-tool-button'), second = wrapper.querySelector('.tp-copy-tool-button'); if (first && second && first.nextElementSibling !== second) wrapper.insertBefore(first, second); }
#findDetailRoot() { return Array.from(document.querySelectorAll('article,[role="article"]')).filter(n => { const r = n.getBoundingClientRect(); return r.width > 260 && r.height > 140 && r.bottom > 0 && r.top < innerHeight; }).sort((a, b) => { const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); return (Math.abs(ra.top - 80) + Math.max(0, ra.left)) - (Math.abs(rb.top - 80) + Math.max(0, rb.left)); })[0] || null; }
#findDetailShareBtn(root) { const detailInfo = DomService.getCurrentDetailPostInfo(); const searchRoots = [{ node: root, rp: true }, { node: document.body, rp: false }]; const seen = new Set(), candidates = []; searchRoots.forEach(({ node: sr, rp }) => { sr.querySelectorAll('svg[aria-label], svg').forEach(svg => { if (seen.has(svg)) return; seen.add(svg); if (!DomService.isShareSvg(svg)) return; const slot = DomService.findShareIconSlot(svg, sr); const r = slot?.getBoundingClientRect?.(); if (!slot || !r || r.width < 18 || r.height < 18) return; if (r.bottom < 0 || r.top > innerHeight) return; const blockRoot = DomService.findPostBlockRoot(slot); const blockInfo = blockRoot ? TextService.findBestPostInfo(blockRoot, slot, true) : null; let score = this.#detailShareScore(root, { slot }, rp); score += blockInfo && detailInfo?.postId && blockInfo.postId === detailInfo.postId ? -50000 : 50000; candidates.push({ svg, slot, rect: r, score }); }); }); candidates.sort((a, b) => a.score - b.score); return candidates[0]?.slot || null; }
#detailShareScore(root, item, rp) { const rr = root?.getBoundingClientRect?.(), r = item.slot.getBoundingClientRect(); let score = 0; if (rp) score -= 20000; if (rr && r.top >= rr.top && r.bottom <= rr.bottom + 120) score -= 10000; score += r.top + Math.abs(r.left - 360) * 0.15 + Math.max(0, r.top - innerHeight) * 10 + Math.max(0, -r.top) * 10; return score; }
}
class ShareMenuModule extends BaseModule {
#pendingContext = null; #suppressUntil = 0; #menuObserver = null; #retryCount = 0; #retryDelays = [100, 150, 250]; #pointerDownHandler;
constructor(bus) { super('share-menu', bus); }
init() { this.#pointerDownHandler = e => this.#captureContext(e); document.addEventListener('pointerdown', this.#pointerDownHandler, true); this.#menuObserver = new MutationObserver(mutations => { if (!this.#pendingContext) return; for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && (node.matches?.('[role="menu"], [role="menuitem"], [role="listbox"]') || node.querySelector?.('[role="menu"], [role="menuitem"], [role="listbox"]'))) { requestAnimationFrame(() => this.#tryInject()); return; } } } }); this.#menuObserver.observe(document.documentElement, { childList: true, subtree: true }); this.bus.on('setting-changed', ({ key }) => { if (key === 'enableLinkCopy') this.refresh(); }); }
refresh() { if (!Config.get('enableLinkCopy')) document.querySelectorAll('.tp-clean-link-menu-item').forEach(el => el.remove()); }
destroy() { if (this.#pointerDownHandler) { document.removeEventListener('pointerdown', this.#pointerDownHandler, true); this.#pointerDownHandler = null; } if (this.#menuObserver) { this.#menuObserver.disconnect(); this.#menuObserver = null; } }
#captureContext(e) { if (!Config.get('enableLinkCopy') || Date.now() < this.#suppressUntil) return; const shareSvg = (() => { const path = e.composedPath?.() || []; return path.find(n => n?.tagName?.toLowerCase?.() === 'svg' && DomService.isShareSvg(n)) || e.target?.closest?.('svg[aria-label]'); })(); if (!shareSvg) return; const shareBtn = DomService.findShareIconSlot(shareSvg, document.body) || DomService.findClickableAncestor(shareSvg, document.body); if (!shareBtn) return; const root = DomService.findPostBlockRoot(shareBtn) || shareBtn.closest?.('article,[role="article"]'); const info = root ? (TextService.findBestPostInfo(root, shareBtn, true) || TextService.findPostInfo(root)) : DomService.parsePostInfoFromUrl(location.href); const cleanUrl = DomService.buildCleanUrl(info || DomService.parsePostInfoFromUrl(location.href)); if (!cleanUrl) return; this.#pendingContext = { cleanUrl, shareButton: shareBtn, createdAt: Date.now() }; this.#retryCount = 0; }
#tryInject() { const ctx = this.#pendingContext; if (!ctx?.cleanUrl || Date.now() - ctx.createdAt > 8000) { this.#pendingContext = null; this.#retryCount = 0; return false; } if (document.querySelector('.tp-clean-link-menu-item')) return true; const native = this.#findNativeItem(); if (!native) { if (this.#retryCount < this.#retryDelays.length) { setTimeout(() => this.#tryInject(), this.#retryDelays[this.#retryCount++]); return false; } this.#pendingContext = null; this.#retryCount = 0; return false; } const cleanItem = native.cloneNode(true); cleanItem.classList.add('tp-clean-link-menu-item'); cleanItem.removeAttribute('id'); cleanItem.querySelectorAll('[id]').forEach(e => e.removeAttribute('id')); this.#replaceLabel(cleanItem); cleanItem.addEventListener('pointerdown', Utils.blockEvent, true); cleanItem.addEventListener('mousedown', Utils.blockEvent, true); cleanItem.addEventListener('click', e => { Utils.blockEvent(e); Utils.copyToClipboard(ctx.cleanUrl); this.#pendingContext = null; this.#retryCount = 0; this.#closeMenu(ctx, cleanItem); }, true); native.before(cleanItem); this.#pendingContext = null; this.#retryCount = 0; return true; }
#findNativeItem() { return Array.from(document.querySelectorAll('[role="menuitem"], [role="button"], button, [tabindex="0"]')).filter(el => !el.classList.contains('tp-clean-link-menu-item')).filter(el => /^(複製連結|Copy link|リンクをコピー|링크 복사)$/i.test((el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim())).filter(el => { const r = el.getBoundingClientRect(); return r.width > 80 && r.height > 24 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; }).sort((a, b) => a.getBoundingClientRect().width * a.getBoundingClientRect().height - b.getBoundingClientRect().width * b.getBoundingClientRect().height)[0] || null; }
#replaceLabel(item) { const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT); const nodes = []; let n = walker.nextNode(); while (n) { nodes.push(n); n = walker.nextNode(); } const target = nodes.find(tn => /^(複製連結|Copy link|リンクをコピー|링크 복사)$/i.test((tn.nodeValue || '').replace(/\s+/g, ' ').trim())); if (target) target.nodeValue = I18n.t('cleanLinkMenu'); item.setAttribute('aria-label', I18n.t('cleanLinkMenu')); item.title = I18n.t('cleanLinkMenu'); }
#closeMenu(ctx, cleanItem) { const t = document.activeElement instanceof Element ? document.activeElement : document.body; t.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', code:'Escape', keyCode:27, which:27, bubbles:true, cancelable:true, composed:true })); t.dispatchEvent(new KeyboardEvent('keyup', { key:'Escape', code:'Escape', keyCode:27, which:27, bubbles:true, cancelable:true, composed:true })); setTimeout(() => { const isVis = el => { if (!el?.isConnected) return false; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0; }; if (!isVis(cleanItem) && !isVis(this.#findNativeItem())) return; if (!ctx?.shareButton?.isConnected) return; this.#suppressUntil = Date.now() + 500; ctx.shareButton.click(); }, 120); }
}
class CarouselModule extends BaseModule {
#lightbox = null; #track = null; #slides = []; #currentIndex = 0; #slideElements = [];
#preloadMap = new Map();
#preloadedIdx = new Set();
#preloadOrder = [];
#onImageClickBound = null; #onKeyDownBound = null;
constructor(bus) { super('carousel', bus); }
init() { this.#onImageClickBound = e => this.#onImageClick(e); this.#onKeyDownBound = e => this.#onKeyDown(e); document.addEventListener('click', this.#onImageClickBound, true); document.addEventListener('keydown', this.#onKeyDownBound); }
destroy() { if (this.#onImageClickBound) { document.removeEventListener('click', this.#onImageClickBound, true); this.#onImageClickBound = null; } if (this.#onKeyDownBound) { document.removeEventListener('keydown', this.#onKeyDownBound); this.#onKeyDownBound = null; } this.#close(); }
refresh() {}
#onImageClick(e) { const img = e.target.closest?.('img'); if (!img) return; if (img.closest('.tp-copy-tool-button, .tp-link-tool-button, #tp-lightbox')) return; const postRoot = img.closest?.('article,[role="article"]') || DomService.findPostBlockRoot(img); if (!postRoot) return; const allImages = Array.from(postRoot.querySelectorAll('img')).filter(im => { const r = im.getBoundingClientRect(); const s = im.src || im.currentSrc || ''; return r.width >= 60 && r.height >= 60 && !/profile_pic|avatar|emoji|sprite|static|favicon/i.test(s); }); if (!allImages.length) return; const urls = allImages.map(im => im.src || im.currentSrc).filter(Boolean); if (!urls.length) return; const startIndex = allImages.indexOf(img); this.#openLightbox(urls, startIndex >= 0 ? startIndex : 0); Utils.blockEvent(e); }
#openLightbox(urls, index) { if (this.#lightbox && !this.#lightbox.hidden) this.#close(); if (!this.#lightbox) this.#createLightbox(); this.#slides = urls; this.#currentIndex = Math.max(0, Math.min(index, urls.length - 1)); this.#buildSlides(); this.#preloadAround(this.#currentIndex); this.#loadImagesAround(this.#currentIndex); const isFade = Config.get('carouselEffect') === 'fade'; this.#track.classList.toggle('tp-lb-fade', isFade); this.#syncActive(); if (!isFade) this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`; this.#lightbox.hidden = false; requestAnimationFrame(() => { this.#lightbox.style.opacity = ''; }); this.#updateCounter(); }
#createLightbox() { this.#lightbox = document.createElement('div'); this.#lightbox.id = 'tp-lightbox'; this.#lightbox.hidden = true; this.#lightbox.style.opacity = '0'; this.#lightbox.innerHTML = `<button class="tp-lb-nav tp-lb-prev" aria-label="${I18n.t('prev')}"><svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg></button><button class="tp-lb-nav tp-lb-next" aria-label="${I18n.t('next')}"><svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg></button><div class="tp-lb-track"></div><div class="tp-lb-counter"></div>`; document.body.appendChild(this.#lightbox); this.#track = this.#lightbox.querySelector('.tp-lb-track'); this.#lightbox.addEventListener('click', e => { if (e.target === this.#lightbox) this.#close(); }); this.#lightbox.querySelector('.tp-lb-prev').addEventListener('click', e => { Utils.blockEvent(e); this.#navigate(-1); }); this.#lightbox.querySelector('.tp-lb-next').addEventListener('click', e => { Utils.blockEvent(e); this.#navigate(1); }); }
#buildSlides() { this.#track.innerHTML = ''; this.#slideElements = []; for (let i = 0; i < this.#slides.length; i++) { const slide = document.createElement('div'); slide.className = 'tp-lb-slide'; slide.dataset.index = i; const spinner = document.createElement('div'); spinner.className = 'tp-lb-spinner'; slide.appendChild(spinner); this.#track.appendChild(slide); this.#slideElements.push(slide); } }
#onImageReady(slide, img) { const activate = () => { void img.offsetWidth; img.classList.add('tp-loaded'); const spinner = slide.querySelector('.tp-lb-spinner'); if (spinner) { spinner.classList.add('tp-spinner-fade'); spinner.addEventListener('transitionend', () => spinner.remove(), { once: true }); } }; if (img.complete) { requestAnimationFrame(() => { void img.offsetWidth; requestAnimationFrame(activate); }); } else { img.addEventListener('load', () => requestAnimationFrame(activate), { once: true }); } }
#loadImagesAround(centerIdx) { const total = this.#slides.length; if (!total) return; const loadRange = new Set(); for (let off = -1; off <= 1; off++) { const idx = centerIdx + off; if (idx >= 0 && idx < total) loadRange.add(idx); } this.#slideElements.forEach((slide, idx) => { if (loadRange.has(idx)) { if (!slide.querySelector('img')) { const spinner = slide.querySelector('.tp-lb-spinner'); if (spinner) spinner.classList.remove('tp-spinner-fade'); const img = document.createElement('img'); img.src = this.#slides[idx]; img.alt = ''; img.loading = 'lazy'; slide.appendChild(img); this.#onImageReady(slide, img); } } else { const img = slide.querySelector('img'); if (img) img.remove(); if (!slide.querySelector('.tp-lb-spinner')) { const s = document.createElement('div'); s.className = 'tp-lb-spinner'; slide.appendChild(s); } } }); }
#preloadAround(centerIdx) {
const total = this.#slides.length;
for (let off = -1; off <= 1; off++) {
const idx = centerIdx + off;
if (idx >= 0 && idx < total && !this.#preloadedIdx.has(idx)) {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = this.#slides[idx];
link.setAttribute('fetchpriority', 'high');
document.head.appendChild(link);
this.#preloadMap.set(idx, link);
this.#preloadedIdx.add(idx);
this.#preloadOrder.push(idx);
if (this.#preloadOrder.length > 10) {
const oldestIdx = this.#preloadOrder.shift();
const oldLink = this.#preloadMap.get(oldestIdx);
if (oldLink) oldLink.remove();
this.#preloadMap.delete(oldestIdx);
this.#preloadedIdx.delete(oldestIdx);
}
}
}
}
#syncActive() { this.#slideElements.forEach((s, i) => s.classList.toggle('tp-active', i === this.#currentIndex)); }
#navigate(delta) { const ni = this.#currentIndex + delta; if (ni < 0 || ni >= this.#slides.length) return; this.#currentIndex = ni; this.#preloadAround(this.#currentIndex); this.#loadImagesAround(this.#currentIndex); if (Config.get('carouselEffect') === 'fade') { this.#syncActive(); } else { this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`; this.#syncActive(); } this.#updateCounter(); }
#goTo(idx) { if (idx < 0 || idx >= this.#slides.length) return; this.#currentIndex = idx; this.#preloadAround(this.#currentIndex); this.#loadImagesAround(this.#currentIndex); if (Config.get('carouselEffect') === 'fade') { this.#syncActive(); } else { this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`; this.#syncActive(); } this.#updateCounter(); }
#updateCounter() { const c = this.#lightbox?.querySelector('.tp-lb-counter'); if (c) c.textContent = `${this.#currentIndex + 1} / ${this.#slides.length}`; }
#close() {
if (!this.#lightbox) return;
this.#lightbox.hidden = true;
this.#lightbox.style.opacity = '0';
this.#slides = [];
this.#slideElements = [];
this.#currentIndex = 0;
for (const link of this.#preloadMap.values()) link.remove();
this.#preloadMap.clear();
this.#preloadedIdx.clear();
this.#preloadOrder = [];
this.#track.replaceChildren();
this.#track.classList.remove('tp-lb-fade');
this.#track.style.transform = '';
}
#onKeyDown(e) { const lb = document.getElementById('tp-lightbox'); if (!lb || lb.hidden) return; if (e.key === 'Escape') this.#close(); else if (e.key === 'ArrowLeft') this.#navigate(-1); else if (e.key === 'ArrowRight') this.#navigate(1); else if (e.key === 'Home') this.#goTo(0); else if (e.key === 'End') this.#goTo(this.#slides.length - 1); }
}
class TimeDisplayModule extends BaseModule {
#timeAttr = 'data-tp-time'; #origAttr = 'data-tp-original-time';
constructor(bus) { super('time-display', bus); }
init() { this.bus.on('setting-changed', ({ key }) => { if (key === 'enableAbsoluteTime') this.refresh(); }); this.bus.on('settings-reset', () => this.refresh()); }
refresh() { const enabled = Config.get('enableAbsoluteTime'); document.querySelectorAll('time[datetime]').forEach(el => this.#process(el, enabled)); }
#process(el, enabled) { const iso = el.getAttribute('datetime'); if (!iso) return; if (enabled) { if (!el.hasAttribute(this.#origAttr)) el.setAttribute(this.#origAttr, el.textContent || ''); const formatted = Utils.formatAbsoluteTime(iso); if (formatted) { el.textContent = formatted; el.setAttribute(this.#timeAttr, ''); } const absSpan = el.parentElement?.querySelector('.tp-abs-time'); if (absSpan) absSpan.remove(); } else { if (el.hasAttribute(this.#origAttr)) { el.textContent = el.getAttribute(this.#origAttr); el.removeAttribute(this.#origAttr); } el.removeAttribute(this.#timeAttr); const absSpan = el.parentElement?.querySelector('.tp-abs-time'); if (absSpan) absSpan.remove(); } }
}
/** 浮動選單模組 — 按鈕移至右上角,對齊右下角發表按鈕的邊距 */
class FloatingMenuModule extends BaseModule {
#btn = null; #menu = null; #isOpen = false; #onClickOutside = null; #styleId = 'tp-floating-menu-style';
constructor(bus) { super('floating-menu', bus); }
init() { this.#injectStyles(); this.#createBtn(); this.bus.on('setting-changed', () => { if (this.#isOpen) this.#renderMenuItems(); }); this.bus.on('settings-reset', () => { if (this.#isOpen) this.#renderMenuItems(); }); }
destroy() { this.#btn?.remove(); this.#closeMenu(); this.#btn = null; }
refresh() { if (!document.body.contains(this.#btn)) document.body.appendChild(this.#btn); }
#createBtn() { if (this.#btn) return; this.#btn = document.createElement('button'); this.#btn.id = 'tp-floating-btn'; this.#btn.innerHTML = '⚙'; this.#btn.title = 'Threads Plus 設定'; this.#btn.addEventListener('pointerdown', (e) => { Utils.blockEvent(e); this.#toggleMenu(); }, true); document.body.appendChild(this.#btn); }
#toggleMenu() { if (this.#isOpen) this.#closeMenu(); else this.#openMenu(); }
#openMenu() { this.#menu = document.createElement('div'); this.#menu.id = 'tp-floating-menu'; this.#renderMenuItems(); document.body.appendChild(this.#menu); this.#btn.classList.add('tp-floating-btn-open'); requestAnimationFrame(() => { void this.#menu.offsetWidth; requestAnimationFrame(() => this.#menu.classList.add('tp-floating-menu-visible')); }); this.#isOpen = true; this.#onClickOutside = (e) => { if (!this.#btn?.contains(e.target) && !this.#menu?.contains(e.target)) this.#closeMenu(); }; document.addEventListener('click', this.#onClickOutside, true); }
#closeMenu() { if (this.#menu) { this.#menu.classList.remove('tp-floating-menu-visible'); this.#btn?.classList.remove('tp-floating-btn-open'); const menu = this.#menu; this.#menu = null; this.#isOpen = false; setTimeout(() => { if (menu.parentNode) menu.remove(); }, 260); } else { this.#isOpen = false; } if (this.#onClickOutside) { document.removeEventListener('click', this.#onClickOutside, true); this.#onClickOutside = null; } }
#renderMenuItems() { if (!this.#menu) return; const items = [ { key: 'enableLinkCopy', labelKey: 'settingsEnableLink' }, { key: 'enableTextCopy', labelKey: 'settingsEnableCopy' }, { key: 'enableAbsoluteTime', labelKey: 'settingsEnableTime' } ]; this.#menu.innerHTML = ''; items.forEach(item => { const row = document.createElement('div'); row.className = 'tp-menu-item'; const isOn = Config.get(item.key); row.innerHTML = `<span class="tp-check">${isOn ? '✔' : '✘'}</span> <span class="tp-label">${I18n.t(item.labelKey)}</span>`; row.addEventListener('pointerdown', (e) => { Utils.blockEvent(e); const newValue = !Config.get(item.key); Config.set(item.key, newValue); const checkEl = row.querySelector('.tp-check'); if (checkEl) this.#animateToggle(checkEl, newValue ? '✔' : '✘'); }); this.#menu.appendChild(row); }); const animRow = document.createElement('div'); animRow.className = 'tp-menu-item'; const effect = Config.get('carouselEffect'); animRow.innerHTML = `<span class="tp-check">⚙</span> <span class="tp-label">${I18n.t('settingsCarouselAnim')}:</span> <span class="tp-mode-text">${effect === 'slide' ? I18n.t('animSlide') : I18n.t('animFade')}</span>`; animRow.addEventListener('pointerdown', (e) => { Utils.blockEvent(e); const next = Config.get('carouselEffect') === 'slide' ? 'fade' : 'slide'; Config.set('carouselEffect', next); const modeEl = animRow.querySelector('.tp-mode-text'); if (modeEl) this.#animateToggle(modeEl, next === 'slide' ? I18n.t('animSlide') : I18n.t('animFade')); }); this.#menu.appendChild(animRow); const resetRow = document.createElement('div'); resetRow.className = 'tp-menu-item'; resetRow.innerHTML = `<span class="tp-check">⟳</span> <span class="tp-label">${I18n.t('settingsReset')}</span>`; resetRow.addEventListener('pointerdown', (e) => { Utils.blockEvent(e); Config.reset(); this.#renderMenuItems(); }); this.#menu.appendChild(resetRow); const targets = this.#menu.querySelectorAll('.tp-check, .tp-mode-text'); targets.forEach(el => { el.style.opacity = '0'; el.style.transform = 'scale(0.5)'; }); requestAnimationFrame(() => { requestAnimationFrame(() => { targets.forEach(el => { el.style.opacity = ''; el.style.transform = ''; }); }); }); }
#animateToggle(el, newContent) { el.style.transition = 'transform 0.15s ease, opacity 0.15s ease'; el.style.transform = 'scale(0)'; el.style.opacity = '0'; const finish = () => { el.removeEventListener('transitionend', finish); el.textContent = newContent; requestAnimationFrame(() => { el.style.transform = 'scale(1)'; el.style.opacity = '1'; }); }; el.addEventListener('transitionend', finish, { once: true }); setTimeout(() => { if (el.style.transform === 'scale(0)') finish(); }, 200); }
#injectStyles() { if (document.getElementById(this.#styleId)) return; const style = document.createElement('style'); style.id = this.#styleId; style.textContent = `#tp-floating-btn { position:fixed; top:24px; right:24px; z-index:2147483640; width:48px; height:48px; border-radius:50%; background:rgba(32,32,34,0.85); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,0.1); color:#e4e6eb; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:0 4px 12px rgba(0,0,0,0.4); font-size:20px; line-height:1; user-select:none; transition:transform 0.3s cubic-bezier(0.4,0,0.2,1); } #tp-floating-btn:hover { background:rgba(50,50,52,0.9); } #tp-floating-btn.tp-floating-btn-open { transform:rotate(90deg); } #tp-floating-menu { position:fixed; top:80px; right:24px; z-index:2147483639; background:rgba(32,32,34,0.95); backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); border:1px solid rgba(255,255,255,0.12); border-radius:14px; padding:8px 0; color:#e4e6eb; font-family:system-ui,sans-serif; font-size:14px; min-width:220px; box-shadow:0 8px 24px rgba(0,0,0,0.5); opacity:0; transform:scale(0.9); transition:opacity 0.25s ease, transform 0.25s cubic-bezier(0.4,0,0.2,1); will-change:opacity,transform; } #tp-floating-menu.tp-floating-menu-visible { opacity:1; transform:scale(1); } .tp-menu-item { padding:10px 20px; cursor:pointer; display:flex; align-items:center; gap:8px; transition:background 0.15s; } .tp-menu-item:hover { background:rgba(255,255,255,0.08); } .tp-check, .tp-mode-text { display:inline-block; will-change:transform,opacity; } .tp-check { width:20px; text-align:center; }`; document.head.appendChild(style); }
}
// ════════════════════════ 啟動 ════════════════════════
StyleManager.inject();
const bus = new EventBus();
const app = new AppKernel(bus);
app.register(CopyButtonsModule);
app.register(ShareMenuModule);
app.register(CarouselModule);
app.register(TimeDisplayModule);
app.register(FloatingMenuModule);
app.start();
})();