FaceBook 貼文懸浮截圖按鈕

在貼文右上新增一個懸浮截圖按鈕,按下後可以對貼文進行截圖保存,方便與其他人分享

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Floating Screenshot Button for Facebook Posts
// @name:zh-TW   FaceBook 貼文懸浮截圖按鈕
// @name:zh-CN   FaceBook 贴文悬浮截图按钮
// @namespace    http://tampermonkey.net/
// @version      4.3
// @description  A floating screenshot button is added to the top-right corner of the post. When clicked, it allows users to capture and save a screenshot of the post, making it easier to share with others.
// @description:zh-TW 在貼文右上新增一個懸浮截圖按鈕,按下後可以對貼文進行截圖保存,方便與其他人分享
// @description:zh-CN 在贴文右上新增一个悬浮截图按钮,按下后可以对贴文进行截图保存,方便与其他人分享
// @author       Hzbrrbmin + ChatGPT + Gemini
// @match        https://www.facebook.com/*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/html-to-image.min.js
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // ===== 禁用聚焦樣式 =====
  const style = document.createElement('style');
  style.textContent = `
    *:focus, *:focus-visible, *:focus-within {
      outline: none !important;
      box-shadow: none !important;
    }
  `;
  document.head.appendChild(style);

  // ===== 補零 =====
  const pad = n => n.toString().padStart(2, '0');

  // ===== 從貼文中取得 FBID =====
  function getFbidFromPost(post) {
    const links = Array.from(post.querySelectorAll('a[href*="fbid="], a[href*="story_fbid="]'));
    for (const a of links) {
      try {
        const url = new URL(a.href);
        const fbid = url.searchParams.get('fbid') || url.searchParams.get('story_fbid');
        if (fbid) return fbid;
      } catch { }
    }
    const dataFt = post.getAttribute('data-ft');
    if (dataFt) {
      const match = dataFt.match(/"top_level_post_id":"(\d+)"/);
      if (match) return match[1];
    }
    try {
      const url = new URL(window.location.href);
      const fbid = url.searchParams.get('fbid') || url.searchParams.get('story_fbid');
      if (fbid) return fbid;
    } catch { }
    return 'unknownFBID';
  }

  // ===== 建立截圖按鈕 =====
  function createScreenshotButton(post, filenameBuilder) {
    const btn = document.createElement('div');
    btn.textContent = '📸';
    btn.title = '截圖貼文';

    // 【關鍵修正 1】給按鈕一個特定的 Class Name,用來給過濾器識別
    btn.classList.add('ignore-me-please');

    Object.assign(btn.style, {
      position: 'absolute', left: '-40px', top: '0',
      width: '32px', height: '32px', display: 'flex',
      alignItems: 'center', justifyContent: 'center',
      borderRadius: '50%', backgroundColor: '#3A3B3C',
      color: 'white', cursor: 'pointer', zIndex: '9999',
      transition: 'background .2s'
    });
    btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#4E4F50');
    btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#3A3B3C');

    btn.addEventListener('click', async e => {
      e.stopPropagation();
      btn.textContent = '⏳';
      btn.style.pointerEvents = 'none';

      const originalMargins = [];

      try {
        // 展開「查看更多」
        post.querySelectorAll('span,a,div,button').forEach(el => {
          const txt = el.innerText?.trim() || el.textContent?.trim();
          if (['查看更多', '顯示更多', 'See more', 'See More', '…更多'].includes(txt)) {
            el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
          }
        });

        await new Promise(r => setTimeout(r, 800));

        // 調整內文 margin
        const storyMessages = post.querySelectorAll('div[dir="auto"], div[data-ad-preview="message"]');
        storyMessages.forEach(el => {
          const computedMargin = window.getComputedStyle(el).marginTop;
          originalMargins.push({ el, margin: computedMargin });
          el.style.marginTop = '10px';
        });

        await new Promise(r => setTimeout(r, 100));

        const filename = filenameBuilder();
        await document.fonts.ready;

        // 【關鍵修正 2】設定截圖參數,使用 filter 強制排除按鈕
        const options = {
          backgroundColor: '#1c1c1d',
          pixelRatio: 2,
          cacheBust: true,
          useCORS: true,
          allowTaint: true,
          // filter 函數:返回 false 代表不截取該元素
          filter: (node) => {
             // 如果節點有 classList 且包含我們定義的 class,就跳過它
             if (node.classList && node.classList.contains('ignore-me-please')) {
                 return false;
             }
             return true;
          }
        };

        const dataUrl = await window.htmlToImage.toPng(post, options);

        const link = document.createElement('a');
        link.href = dataUrl;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        btn.textContent = '📸'; // 截圖完成後恢復相機圖標

      } catch (err) {
        console.error('截圖錯誤:', err);
        alert('截圖失敗,請稍後再試');
        btn.textContent = '❌';
      } finally {
        // 還原原本的 margin-top
        originalMargins.forEach(({ el, margin }) => {
          el.style.marginTop = margin;
        });

        btn.style.pointerEvents = 'auto';
      }
    });

    return btn;
  }

  // ===== 判斷頁面類型 =====
  function getPageType(path) {
    if (path.startsWith('/groups/')) return 'group';
    const segments = path.split('/').filter(Boolean);
    const excluded = ['watch', 'gaming', 'marketplace', 'groups', 'friends', 'notifications', 'messages'];
    if (segments.length > 0 && !excluded.includes(segments[0])) return 'page';
    return 'home';
  }

  // ===== 核心觀察器 (維持您原本的選擇器邏輯) =====
  const observer = new MutationObserver(() => {
    const type = getPageType(location.pathname);

    if (type === 'home') {
      document.querySelectorAll('div.x1lliihq').forEach(post => {
        if (post.dataset.sbtn === '1') return;
        const textContent = post.innerText || post.textContent || '';
        if (textContent.includes('社團建議') || textContent.includes('Suggested Groups')) return;

        let btnGroup = post.querySelector('div[role="group"]')
          || post.querySelector('div.xqcrz7y')
          || post.querySelector('div.x1qx5ct2');
        if (!btnGroup) return;

        post.dataset.sbtn = '1';
        btnGroup.style.position = 'relative';
        const fbid = getFbidFromPost(post);
        btnGroup.appendChild(createScreenshotButton(post, () => {
          const now = new Date();
          return `${fbid}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}.png`;
        }));
      });
    }

    if (type === 'group' || type === 'page') {
      document.querySelectorAll('div.x1yztbdb').forEach(post => {
        if (post.dataset.sbtn === '1') return;
        let btnParent = post.querySelector('div.xqcrz7y') || post.closest('div.xqcrz7y');
        if (!btnParent) return;

        post.dataset.sbtn = '1';
        btnParent.style.position = 'relative';

        btnParent.appendChild(createScreenshotButton(post, () => {
          const now = new Date();
          if (type === 'group') {
            const groupId = location.pathname.match(/^\/groups\/(\d+)/)?.[1] || 'unknownGroup';
            return `${groupId}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}.png`;
          } else {
            const pageName = location.pathname.split('/').filter(Boolean)[0] || 'page';
            return `${pageName}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}.png`;
          }
        }));
      });
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });
})();