FaceBook 貼文懸浮截圖按鈕

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

// ==UserScript==
// @name         Floating Screenshot Button for Facebook Posts
// @name:zh-TW   FaceBook 貼文懸浮截圖按鈕
// @name:zh-CN   FaceBook 贴文悬浮截图按钮
// @namespace    http://tampermonkey.net/
// @version      3.6
// @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       chatgpt
// @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 screenshotOptions = {
    backgroundColor: '#1c1c1d',
    pixelRatio: 2,
    cacheBust: true
  };

  // ===== 補零 =====
  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 = '截圖貼文';
    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';

      // 用於儲存原始 margin-top
      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-top,避免文字被頭像遮擋
        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 = '20px';
        });

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

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

        const dataUrl = await window.htmlToImage.toPng(post, screenshotOptions);
        const link = document.createElement('a');
        link.href = dataUrl;
        link.download = filename;
        link.click();

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

      setTimeout(() => {
        btn.textContent = '📸';
        btn.style.pointerEvents = 'auto';
      }, 1000);
    });

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