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.

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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);

})();