專為 Threads 網頁版開發的輕量型腳本,提供一鍵複製串文連結(無追蹤碼)、一鍵複製串文內容按鈕、絕對時間顯示、圖片輪播動畫等功能,讓瀏覽體驗更流暢!所有設定現已整合至懸浮按鈕(位於右上角,對齊發表按鈕)。 (極致細膩的圖片輪播動畫,平滑與淡入模式差異鮮明)
// ==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.4
// @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. (Ultra-refined carousel animations with distinct slide/fade modes)
// @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. (Ultra-refined carousel animations with distinct slide/fade modes)
// @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;
}
}
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);
}
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) {
/* ignore */
}
}
}
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) {
if (node.matches?.('a[href*="/post/"]')) {
return DomService.parsePostInfoFromUrl(node.href);
}
return 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 .35s cubic-bezier(0.4,0,0.2,1)!important}
#tp-lightbox[hidden]{display:none!important;opacity:0!important}
#tp-lightbox:not([hidden]){opacity:1!important}
/* 平滑模式使用 translateX,淡入模式不使用 */
#tp-lightbox .tp-lb-track{display:flex!important;height:100%!important;align-items:center!important;will-change:transform!important;transition:transform .72s cubic-bezier(0.2,0.9,0.3,1)!important}
#tp-lightbox .tp-lb-track.tp-lb-fade{display:block!important;transition:none!important;will-change:auto!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}
/* 圖片輪播動畫:平滑模式 */
#tp-lightbox .tp-lb-track:not(.tp-lb-fade) .tp-lb-slide{
transition:transform .72s cubic-bezier(0.2,0.9,0.3,1),opacity .72s cubic-bezier(0.2,0.9,0.3,1),filter .72s cubic-bezier(0.2,0.9,0.3,1)!important;
transform:scale(.88)!important; opacity:.25!important; filter:blur(8px)!important;
will-change:transform,opacity,filter!important
}
#tp-lightbox .tp-lb-track:not(.tp-lb-fade) .tp-lb-slide.tp-active{
transform:scale(1)!important; opacity:1!important; filter:blur(0)!important
}
/* 圖片輪播動畫:淡入模式 */
#tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide{
position:absolute!important; inset:0!important;
transition:opacity .85s cubic-bezier(0.4,0,0.2,1),transform .85s cubic-bezier(0.4,0,0.2,1)!important;
opacity:0!important; transform:scale(.95)!important;
z-index:0!important; pointer-events:none!important;
will-change:opacity,transform!important
}
#tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide.tp-active{
opacity:1!important; transform:scale(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.03); filter:blur(3px);
transition:opacity .6s cubic-bezier(0.4,0,0.2,1),transform .6s cubic-bezier(0.4,0,0.2,1),filter .6s cubic-bezier(0.4,0,0.2,1)!important;
will-change:opacity,transform,filter!important
}
#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 .4s cubic-bezier(0.4,0,0.2,1)!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;transition:background .25s cubic-bezier(0.4,0,0.2,1)!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.querySelector('[data-testid="primaryColumn"]') ||
document.querySelector('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() {
const articles = document.querySelectorAll('article, [role="article"]');
return Array.from(articles)
.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() {
const items = document.querySelectorAll('[role="menuitem"], [role="button"], button, [tabindex="0"]');
return Array.from(items)
.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 = [];
#preloadImages = 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, true);
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 removeSpinner = () => {
const spinner = slide.querySelector('.tp-lb-spinner');
if (spinner) {
spinner.classList.add('tp-spinner-fade');
spinner.addEventListener('transitionend', () => spinner.remove(), { once: true });
}
};
const showImage = () => {
requestAnimationFrame(() => {
img.classList.add('tp-loaded');
removeSpinner();
});
};
const onError = () => { img.remove(); removeSpinner(); };
if (img.complete) {
if (img.naturalWidth === 0 && img.naturalHeight === 0) {
onError();
} else {
requestAnimationFrame(() => requestAnimationFrame(showImage));
}
} else {
img.addEventListener('load', () => requestAnimationFrame(showImage), { once: true });
img.addEventListener('error', onError, { once: true });
}
}
#loadImagesAround(centerIdx, isInitial = false) {
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 preloadedImg = this.#preloadImages.get(idx);
if (preloadedImg && preloadedImg.complete && preloadedImg.naturalWidth > 0) {
preloadedImg.classList.remove('tp-loaded');
slide.appendChild(preloadedImg);
this.#onImageReady(slide, preloadedImg);
} else {
const img = document.createElement('img');
img.src = this.#slides[idx];
img.alt = '';
img.loading = (isInitial && idx === centerIdx) ? 'eager' : 'lazy';
if (idx === centerIdx) img.setAttribute('fetchpriority', 'high');
slide.appendChild(img);
this.#onImageReady(slide, img);
}
}
} else {
const img = slide.querySelector('img');
if (img) img.classList.remove('tp-loaded');
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;
const maxPreload = 6;
for (let off = -1; off <= 1; off++) {
const idx = centerIdx + off;
if (idx >= 0 && idx < total && !this.#preloadedIdx.has(idx)) {
const img = new Image();
img.src = this.#slides[idx];
this.#preloadImages.set(idx, img);
this.#preloadedIdx.add(idx);
this.#preloadOrder.push(idx);
if (this.#preloadOrder.length > maxPreload) {
const oldestIdx = this.#preloadOrder.shift();
this.#preloadImages.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.#preloadImages.values()) link.remove();
this.#preloadImages.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: max(24px, env(safe-area-inset-top, 0px));
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: calc(max(24px, env(safe-area-inset-top, 0px)) + 56px);
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();
})();