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)

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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

})();