Twitter Virastar Integration

ویراستارِ توییت‌های فارسی در X (Twitter)

// ==UserScript==
// @name         Twitter Virastar Integration
// @version      0.1.1
// @description  ویراستارِ توییت‌های فارسی در X (Twitter)
// @homepage     https://github.com/Amm1rr/Twitter-Virastar-Integration/
// @namespace    amm1rr.com.virastar
// @match        https://x.com/*
// @require      https://update.greasyfork.org/scripts/527228/1538801/Virastar%20Library.js
// @grant        none
// @license      MIT
// ==/UserScript==

/*
 *  توییتر از کتابخانه‌ی Draft.js برای فیلد متنی استفاده می‌کند که مدیریت State در React را پیچیده می‌سازد.
 *  تغییر مستقیم مقدار فیلد ممکن است عملکرد کلیدهای Backspace و Delete را مختل کند،
 *  مخصوصاً اگر متن از طریق insertText یا روش‌های مشابه تزریق شود.
 *  برای جلوگیری از این مشکل، از DataTransfer (رویداد Paste) بهره می‌گیریم تا متن را به‌شکل صحیح وارد فیلد کنیم
 *  و از تداخل با State داخلی Draft.js پرهیز شود.
 *
 *  این اسکریپت یکی از روش‌های کم‌دردسر برای ادغام با توییتر (X) است.
 *  در هر جایی که دکمه‌ی Tweet یا Reply (با data-testid) اضافه شود، در صورت وجود فیلد متنی، یک دکمه‌ی «ویراستار» نیز افزوده می‌گردد.
 *  بدین‌ترتیب تداخلی با ساختار یا ویژگی‌های توییتر ایجاد نخواهد شد.
 */

(function () {
  "use strict";

  // رنگ‌ها و ثابت‌ها
  const COLORS = {
    GRAY: "#ccc",
    GREEN: "#28a745",
    HIGHLIGHT: "#d4f8d4",
    TRANSPARENT: "transparent",
    TEXT_HIGHLIGHT: "#302f2f",
  };

  const TRANSITION_STYLE = "background-color 0.5s ease";

  const SELECTORS = {
    // دو حالت دکمه‌ی توییتر: Post و Reply
    TWEET_BUTTON:
      '[data-testid="tweetButtonInline"], [data-testid="tweetButton"]',
    // فیلد متنی اصلی مبتنی بر Draft.js
    TWEET_FIELD: '[data-testid="tweetTextarea_0"]',
  };

  const TIMING = {
    PROCESSING_DELAY: 300,
    TEXT_HIGHLIGHT: 1000,
    RESET_DELAY: 1250,
    UI_UPDATE: 100,
  };

  // مراجع سراسری برای مدیریت دکمه‌ی ویراستار و جلوگیری از ساخت تکراری
  let lastTweetButtonRef = null;
  let lastVirastarButtonRef = null;

  // توابع کمکی متداول

  // تاخیر ساده بر اساس Promise
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  // بررسی شروع متن با حروف فارسی
  const isPersian = (text) => /^[\u0600-\u06FF\u0750-\u077F]/.test(text);

  // پاک‌کردن فیلد متنی از طریق ClipboardEvent
  function clearTweetField(tweetField) {
    const selection = window.getSelection();
    const range = document.createRange();
    range.selectNodeContents(tweetField);
    selection.removeAllRanges();
    selection.addRange(range);

    const dt = new DataTransfer();
    dt.setData("text/plain", "");
    const pasteEvent = new ClipboardEvent("paste", {
      bubbles: true,
      cancelable: true,
      clipboardData: dt,
    });
    tweetField.dispatchEvent(pasteEvent);
  }

  // درج متن تمیزشده در فیلد، با استفاده از DataTransfer برای ناسازگارنشدن با Draft.js
  function pasteText(tweetField, text) {
    const dt = new DataTransfer();
    dt.setData("text/plain", text);
    // تبدیل Line Breakها به <br> مطابق با ساختار Draft.js
    dt.setData("text/html", text.replace(/\n/g, "<br>"));

    const pasteEvent = new ClipboardEvent("paste", {
      bubbles: true,
      cancelable: true,
      clipboardData: dt,
    });
    tweetField.dispatchEvent(pasteEvent);
  }

  // قراردادن کرسر در انتهای فیلد متنی
  function setCursorToEnd(tweetField) {
    const selection = window.getSelection();
    const range = document.createRange();
    range.selectNodeContents(tweetField);
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
  }

  // بروزرسانی فیلد متنی با متن ویراسته و نمایش افکت رنگی
  async function updateTweetText(processedText) {
    const tweetField = document.querySelector(SELECTORS.TWEET_FIELD);
    if (!tweetField) return;

    tweetField.focus();
    clearTweetField(tweetField);
    await delay(50);
    pasteText(tweetField, processedText);

    tweetField.style.transition = TRANSITION_STYLE;
    tweetField.style.backgroundColor = COLORS.HIGHLIGHT;
    requestAnimationFrame(() => {
      setTimeout(
        () => (tweetField.style.backgroundColor = COLORS.TRANSPARENT),
        TIMING.TEXT_HIGHLIGHT
      );
    });

    await delay(TIMING.UI_UPDATE);
    setCursorToEnd(tweetField);
  }

  /**
   * ایجاد دکمه‌ی ویراستار کنار دکمه‌ی Tweet/Reply جدید
   * - اگر دکمه‌ی قبلی از DOM حذف شده باشد، دکمه‌ی ویراستارش را هم پاک می‌کنیم.
   * - از ساخت مجدد و تکراری دکمه‌ی ویراستار جلوگیری می‌کنیم.
   */
  function createVirastarButton(tweetButton) {
    // اگر دکمه‌ی قبلی وجود داشته ولی از صفحه حذف شده، دکمه‌ی ویراستار آن هم پاک شود
    if (lastTweetButtonRef && !document.contains(lastTweetButtonRef)) {
      if (lastVirastarButtonRef && lastVirastarButtonRef.parentElement) {
        lastVirastarButtonRef.remove();
      }
      lastVirastarButtonRef = null;
      lastVirastarButtonRef = null;
    }

    // اگر این دکمه عیناً همان دکمه‌ی قبلی است، دوباره نساز
    if (tweetButton === lastTweetButtonRef) {
      return;
    }

    // چک کنیم اگر در والد همین دکمه، ویراستار ساخته شده، تکراری نسازیم
    if (tweetButton.parentElement.querySelector("#virastar-button")) {
      return;
    }

    // اگر قصد داشتید همیشه فقط یکی بسازید، باید دکمه‌ی قبلی را حذف کنید؛
    // اما در اینجا شما می‌خواهید با بسته‌شدن دیالوگ، دکمه‌ی قبلی باقی بماند.

    // بنابراین دکمه‌ی جدید را می‌سازیم و مرجع آن را حفظ می‌کنیم
    lastTweetButtonRef = tweetButton;

    const editButton = document.createElement("button");
    editButton.id = "virastar-button";
    editButton.textContent = "ویراستار ✍️";
    editButton.disabled = true;
    editButton.style.cssText = `
    margin-left: 10px;
    padding: 8px 12px;
    border: none;
    border-radius: 9999px;
    background-color: ${COLORS.GRAY};
    color: white;
    cursor: default;
    font-size: 14px;
    transition: background-color 0.3s, transform 0.2s;
    width: 100px;
    text-align: center;
    `;

    lastVirastarButtonRef = editButton;

    // هماهنگ‌سازی رنگ دکمه‌ی ویراستار با دکمه‌ی اصلی توییتر
    const tweetButtonStyles = window.getComputedStyle(tweetButton);
    const tweetButtonBackgroundColor = tweetButtonStyles.backgroundColor;

    // رویدادها جهت افکت Hover
    editButton.addEventListener("mouseover", () => {
      if (!editButton.disabled) {
        editButton.style.backgroundColor = COLORS.TEXT_HIGHLIGHT;
      }
    });
    editButton.addEventListener("mouseout", () => {
      if (!editButton.disabled) {
        editButton.style.backgroundColor = tweetButtonBackgroundColor;
      }
    });

    // رویدادها برای فعال/غیرفعال کردن دکمه بر اساس متن فیلد
    const tweetField = document.querySelector(SELECTORS.TWEET_FIELD);
    if (tweetField) {
      let cachedText = "";
      const updateButtonState = () => {
        const text = tweetField.innerText.trim();
        cachedText = text;
        const hasText = text.length > 0;
        editButton.disabled = !hasText;
        editButton.style.backgroundColor = hasText
          ? tweetButtonBackgroundColor
          : COLORS.GRAY;
        editButton.style.cursor = hasText ? "pointer" : "default";

        if (hasText) {
          tweetField.style.direction = isPersian(text) ? "rtl" : "ltr";
        }
      };

      // گوش‌دادن به تغییرات محتوای فیلد Draft.js
      ["input", "keyup", "compositionend", "textInput"].forEach((ev) => {
        tweetField.addEventListener(ev, updateButtonState);
      });

      // کلیک روی دکمه‌ی ویراستار برای اصلاح متن
      editButton.addEventListener("click", async () => {
        if (editButton.disabled) return;
        editButton.disabled = true;
        editButton.textContent = "... ⏳";
        editButton.style.transform = "scale(0.95)";
        editButton.style.cursor = "default";

        await delay(TIMING.PROCESSING_DELAY);
        const processed = new Virastar().cleanup(cachedText);
        await updateTweetText(processed);

        editButton.textContent = "✅";
        editButton.style.backgroundColor = COLORS.GREEN;

        await delay(TIMING.RESET_DELAY);
        editButton.textContent = "ویراستار ✍️";
        editButton.style.backgroundColor = tweetButtonBackgroundColor;
        editButton.disabled = false;
        editButton.style.transform = "scale(1)";
        editButton.style.cursor = "pointer";
      });

      // مقدار اولیه برای فعال/غیرفعال
      updateButtonState();
    }

    // افزودن دکمه به والد دکمه‌ی Tweet/Reply
    tweetButton.parentElement.appendChild(editButton);
  }

  /*
   *    MutationObserver:
   *    فقط نودهای جدیدی که در صفحه اضافه می‌شوند بررسی می‌کنیم
   *    تا در کل سند جست‌وجوی تکراری و سنگین انجام نگیرد.
   */
  const observer = new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
      if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType === Node.ELEMENT_NODE) {
            // اگر گره اضافه‌شده مستقیماً دکمه‌ی توییتر باشد
            if (node.matches?.(SELECTORS.TWEET_BUTTON)) {
              if (node.offsetParent !== null) {
                createVirastarButton(node);
              }
            } else {
              // یا اگر در فرزندان آن یک دکمه‌ی توییتر باشد
              const btn = node.querySelector?.(SELECTORS.TWEET_BUTTON);
              if (btn && btn.offsetParent !== null) {
                createVirastarButton(btn);
              }
            }
          }
        }
      }
    }
  });

  // نظارت بر تغییرات در کل صفحه
  observer.observe(document.body, { childList: true, subtree: true });
})();