Reddit Auto RTL (posts & comments)

Auto-detect RTL text on reddit and set direction/text-align for those blocks (posts, comments, inputs). Works on new.reddit.com and old.reddit.com. Uses MutationObserver for live content.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Reddit Auto RTL (posts & comments)
// @namespace    https://github.com/aboda7m/reddit-auto-rtl
// @version      1.1.1
// @description  Auto-detect RTL text on reddit and set direction/text-align for those blocks (posts, comments, inputs). Works on new.reddit.com and old.reddit.com. Uses MutationObserver for live content.
// @author       Aboda7m (Abdulrahman Alnotefi)
// @match        https://*.reddit.com/*
// @match        https://reddit.com/*
// @grant        none
// @run-at       document-end
// @license      MIT
// @homepageURL  https://github.com/aboda7m/reddit-auto-rtl
// @supportURL   https://github.com/aboda7m/reddit-auto-rtl/issues
// ==/UserScript==




(function () {
  'use strict';

  // RTL unicode ranges (trimmed to the important blocks)
  const RTL_RANGES = [
    [0x0590, 0x05FF], // Hebrew
    [0x0600, 0x06FF], // Arabic
    [0x0750, 0x077F], // Arabic Supplement
    [0x08A0, 0x08FF], // Arabic Extended-A
    [0x0700, 0x074F], // Syriac
    [0x0780, 0x07BF], // Thaana
    [0xFB50, 0xFDFF], // Arabic Presentation Forms-A
    [0xFE70, 0xFEFF], // Arabic Presentation Forms-B
    [0x1EE00, 0x1EEFF] // Arabic Mathematical Alphabets etc
  ];

  function isRtlChar(cp) {
    for (let r of RTL_RANGES) if (cp >= r[0] && cp <= r[1]) return true;
    return false;
  }

  function textIsRTL(text) {
    if (!text) return false;
    for (let i = 0; i < text.length; i++) {
      const code = text.charCodeAt(i);
      // skip whitespace and common punctuation quickly
      if (code <= 32 || (code >= 33 && code <= 47)) continue;
      if (isRtlChar(code)) return true;
      // if we see strong LTR latin letter early, prefer LTR
      if ((code >= 0x0041 && code <= 0x007A) || (code >= 0x00C0 && code <= 0x024F)) return false;
    }
    return false;
  }

  // Narrow selectors focused on post/comment content. Avoid generic selectors that hit nav/menus.
  const TARGET_SELECTORS = [
    // new reddit
    'div[data-test-id="post-content"]',
    '.Post div[data-click-id="text"]',     // post rich text container
    '.Post .RichTextJSON-root',            // post richtext root
    '.Post h1, .Post h2, .Post h3',        // post titles in Post scope
    // comment containers (new reddit)
    '.Comment .RichTextJSON-root',
    '.Comment .md',                        // comment markdown inside Comment scope
    // elements with id patterns reddit uses for rtjson content (t3_ = post, t1_ = comment)
    'div[id^="t3_"][id$="-post-rtjson-content"]',
    'div[id^="t1_"][id$="-comment-rtjson-content"]',
    // legacy or shadow wrappers that hold post text
    'shreddit-post-text-body',
    // inputs inside comment/post composer areas (we still restrict to visible inputs)
    'textarea[name], input[type="text"], input[type="search"]'
  ];

  // keep track of nodes already processed
  const processed = new WeakSet();

  function getTextForNode(node) {
    if (!node) return '';
    let t = node.textContent || '';
    // collapse whitespace and trim
    t = t.replace(/\s+/g, ' ').trim();
    if (t.length > 5000) t = t.slice(0, 5000);
    return t;
  }

  function elementHasExplicitDirOrIsUi(el) {
    try {
      if (!el || el.nodeType !== 1) return false;
      // explicit dir attribute means site already decided direction; don't override
      if (el.hasAttribute('dir')) return true;
      // if computed style already rtl this is likely intentional: skip
      const cs = window.getComputedStyle(el);
      if (cs && cs.direction === 'rtl') return true;
    } catch (e) {}
    return false;
  }

  // determine whether node is really a post or comment body area by checking closest ancestors
  function isPostOrCommentScope(el) {
    if (!el || el.nodeType !== 1) return false;
    try {
      // check for explicit wrappers that indicate post/comment scope
      const scope = el.closest(
        'article.Post, .Post, .Comment, [data-test-id="post-content"], shreddit-post-text-body, [id^="t3_"], [id^="t1_"]'
      );
      if (!scope) return false;

      // extra safeguard: avoid applying inside common chrome regions
      // if the scope is inside header/nav/aside or topbar, treat as not a content scope
      if (scope.closest('header, nav, aside, .side, .TopNav, #header, [role="navigation"], .nav, .navbar')) return false;

      // if scope has classes that look like UI chrome (e.g., tooltips, menus), skip
      const className = scope.className || '';
      if (typeof className === 'string' && /(menu|tooltip|dropdown|popover|modal|banner|nav|header|footer|side|sidebar)/i.test(className)) {
        // but don't reject if it's clearly Post or Comment
        if (!/Post|Comment|post|comment/i.test(className)) return false;
      }

      return true;
    } catch (e) {
      return false;
    }
  }

  function applyRtlToElement(el) {
    if (!el || processed.has(el)) return;
    // never touch <body> or html roots
    if (el === document.documentElement || el === document.body) { processed.add(el); return; }

    // if any ancestor set dir explicitly, do not override
    for (let a = el; a && a.nodeType === 1; a = a.parentNode) {
      if (a.hasAttribute && a.hasAttribute('dir')) { processed.add(el); return; }
    }

    const text = getTextForNode(el);
    if (!text) { processed.add(el); return; }

    if (!textIsRTL(text)) { processed.add(el); return; }

    // only apply if this element is within a post or comment scope
    if (!isPostOrCommentScope(el)) { processed.add(el); return; }

    try {
      // prefer native attribute first
      el.setAttribute('dir', 'rtl');
      // inline style fallback to win against strong site css where necessary
      el.style.textAlign = 'right';
      el.style.unicodeBidi = 'plaintext';

      // ensure code/pre inside remain LTR for readability
      const codeBlocks = el.querySelectorAll && el.querySelectorAll('code, pre');
      if (codeBlocks && codeBlocks.length) {
        for (let i = 0; i < codeBlocks.length; i++) {
          const cb = codeBlocks[i];
          cb.setAttribute('dir', 'ltr');
          cb.style.textAlign = 'left';
          cb.style.unicodeBidi = 'embed';
        }
      }
    } catch (e) {
      // ignore and mark processed
      console.error('applyRtlToElement error', e);
    } finally {
      processed.add(el);
    }
  }

  function scanRoot(root = document) {
    try {
      for (const sel of TARGET_SELECTORS) {
        let list = [];
        try {
          list = root.querySelectorAll ? root.querySelectorAll(sel) : [];
        } catch (e) {
          // selector might fail on some roots (shadow/other). skip.
          continue;
        }
        for (let i = 0; i < list.length; i++) {
          const el = list[i];
          // skip UI / chrome obvious nodes quickly
          if (elementHasExplicitDirOrIsUi(el)) { processed.add(el); continue; }
          // for inputs handle separately
          if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && /(text|search)/i.test(el.type))) {
            // attach input listener (handled elsewhere) but do an initial check
            try {
              if (textIsRTL(el.value || el.textContent || '')) {
                if (isPostOrCommentScope(el)) {
                  el.setAttribute('dir', 'rtl');
                  el.style.textAlign = 'right';
                  el.style.unicodeBidi = 'plaintext';
                }
              }
            } catch (e) {}
            processed.add(el);
            continue;
          }
          applyRtlToElement(el);
        }
      }
    } catch (e) {
      console.error('scanRoot error', e);
    }
  }

  function attachInputListeners(root = document) {
    try {
      const inputs = root.querySelectorAll ? root.querySelectorAll('textarea, input[type="text"], input[type="search"]') : [];
      for (let i = 0; i < inputs.length; i++) {
        const el = inputs[i];
        if (el._rtl_listener) continue;
        const fn = () => {
          try {
            if (textIsRTL(el.value || el.textContent || '')) {
              if (isPostOrCommentScope(el)) {
                el.setAttribute('dir', 'rtl');
                el.style.textAlign = 'right';
                el.style.unicodeBidi = 'plaintext';
              }
            } else {
              // If not RTL, only remove attributes we set; don't touch other attributes
              if (el.getAttribute('dir') === 'rtl') el.removeAttribute('dir');
              // clear inline style properties we used, but preserve other inline styles by only touching these props
              el.style.textAlign = '';
              el.style.unicodeBidi = '';
            }
          } catch (e) {}
        };
        el.addEventListener('input', fn);
        el._rtl_listener = true;
        fn(); // initial
      }
    } catch (e) {}
  }

  function observeDom() {
    const mo = new MutationObserver((records) => {
      for (const r of records) {
        if (r.addedNodes && r.addedNodes.length) {
          for (let i = 0; i < r.addedNodes.length; i++) {
            const n = r.addedNodes[i];
            if (!n) continue;
            if (n.nodeType === Node.ELEMENT_NODE) {
              // scan subtree element for our targeted selectors
              scanRoot(n);
              attachInputListeners(n);
              // handle potential open shadow roots
              try {
                if (n.shadowRoot) { scanRoot(n.shadowRoot); attachInputListeners(n.shadowRoot); }
                const kids = n.querySelectorAll && n.querySelectorAll('*');
                if (kids) {
                  for (let j = 0; j < kids.length; j++) {
                    const k = kids[j];
                    if (k.shadowRoot) { scanRoot(k.shadowRoot); attachInputListeners(k.shadowRoot); }
                  }
                }
              } catch (e) {}
            } else if (n.nodeType === Node.TEXT_NODE) {
              const p = n.parentNode;
              if (p) applyRtlToElement(p);
            }
          }
        }
        if (r.type === 'characterData') {
          const p = r.target && r.target.parentNode;
          if (p) applyRtlToElement(p);
        }
      }
    });

    mo.observe(document, { childList: true, subtree: true, characterData: true });
  }

  // initial run
  scanRoot();
  attachInputListeners();
  observeDom();

  // periodic fallback scan for rare cases; clear after 2 minutes
  const intervalId = setInterval(() => {
    scanRoot();
    attachInputListeners();
  }, 3000);
  setTimeout(() => clearInterval(intervalId), 2 * 60 * 1000);

})();