Threads Plus

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)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==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();

})();