Auto-RTL

Content-only RTL: p/li/blockquote + spans in content areas. First alphabetic char decides. Ultra-light (IO/MO/RAF). Per-domain toggle (OFF by default). Ctrl+Alt+R hotkey. Tiny, transparent edge toggle near scrollbar.

// ==UserScript==
// @name         Auto-RTL
// @namespace    https://greasyfork.org/en/users/1374110-momo21798
// @version      0.4.3
// @description  Content-only RTL: p/li/blockquote + spans in content areas. First alphabetic char decides. Ultra-light (IO/MO/RAF). Per-domain toggle (OFF by default). Ctrl+Alt+R hotkey. Tiny, transparent edge toggle near scrollbar.
// @author       Momo
// @match        *://*/*
// @run-at       document-idle
// @grant        none
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  // ---------- Config ----------
  const RTL_CLASS = 'rtl-first-alpha-ar';
  const UI_ID = 'rtl-first-alpha-toggle';
  const STYLE_ID = 'rtl-first-alpha-style';
  const DOMAIN_KEY = (host) => `rtl-first-alpha:domain:${host}`; // per-domain state
  const host = location.hostname;

  // Content containers (safe defaults; extend as needed)
  const CONTENT_SELECTORS = [
    'main', 'article', '[role="main"]', '[role="article"]',
    '.content', '.post', '.entry', '.article', '.story', '.note', '.prose', '.markdown-body',
    '#content', '#main',
    // Substack helpers (harmless elsewhere)
    '.feedCommentBody-UWho7S', '[class*="feedCommentBody-"]', '[data-testid="note-body"]'
  ];

  // Elements to target inside content
  const TARGET_TAGS = new Set(['P', 'LI', 'BLOCKQUOTE', 'SPAN']);

  // Skip inside these (avoid nav/buttons/labels/inputs/etc.)
  const SKIP_ANCESTOR = 'a, button, [role="button"], [role="link"], nav, header, footer, aside, [contenteditable="true"], input, textarea, select, label';

  // ---------- Persistence ----------
  const getDomainState = () => localStorage.getItem(DOMAIN_KEY(host)) ?? 'off'; // default OFF globally
  const setDomainState = (state) => localStorage.setItem(DOMAIN_KEY(host), state);
  const isOn = () => getDomainState() === 'on';

  // ---------- Styles (toggle is tiny, elegant, transparent) ----------
  const STYLES = `
.${RTL_CLASS} {
  direction: rtl !important;
  unicode-bidi: plaintext !important;
  text-align: justify !important;
  text-align-last: right;
}
.${RTL_CLASS} * {
  direction: inherit !important;
  unicode-bidi: inherit !important;
  text-align: inherit !important;
}

/* tiny pill toggle near scrollbar, mid-right */
#${UI_ID} {
  position: fixed;
  top: 50%;
  right: 6px;
  transform: translateY(-50%);
  z-index: 2147483646;
  width: 38px;
  height: 20px;
  border-radius: 999px;
  background: rgba(20,20,20,.25);
  box-shadow: 0 2px 8px rgba(0,0,0,.15);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  display: inline-flex;
  align-items: center;
  padding: 2px;
  cursor: pointer;
  opacity: .22;
  transition: opacity .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease;
  outline: none;
}
#${UI_ID}:hover { opacity: .95; background: rgba(20,20,20,.65); }
#${UI_ID}:active { transform: translateY(-50%) scale(0.98); }
#${UI_ID}.on { background: rgba(9,140,110,.55); }
#${UI_ID}.on:hover { background: rgba(9,140,110,.75); }

#${UI_ID} .knob {
  width: 16px; height: 16px; border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgba(0,0,0,.4);
  transform: translateX(0);
  transition: transform .18s ease;
}
#${UI_ID}.on .knob { transform: translateX(18px); }

/* Focus ring for keyboard users */
#${UI_ID}:focus-visible {
  box-shadow: 0 0 0 2px rgba(255,255,255,.9), 0 0 0 5px rgba(20,20,20,.6);
  opacity: .98;
}

/* Keep clear of notches / overlay scrollbars */
@supports (env(safe-area-inset-right: 0)) {
  #${UI_ID} { right: calc(6px + env(safe-area-inset-right)); }
}

/* optional: avoid hover opacity bump on small touch devices */
@media (hover: none) and (pointer: coarse) {
  #${UI_ID} { opacity: .6; }
}
`;

  // ---------- Unicode helpers ----------
  let LETTER_RE, ARABIC_LETTER_RE;
  try {
    LETTER_RE = /\p{L}/u;                 // any Unicode letter
    ARABIC_LETTER_RE = /^\p{Script=Arabic}$/u; // Arabic-script letter
  } catch {
    LETTER_RE = /[A-Za-z\u00C0-\u02AF\u0370-\u03FF\u0400-\u052F\u0531-\u058F\u10A0-\u10FF\u1E00-\u1EFF\u2C00-\u2C7F\uA720-\uA7FF\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/;
    ARABIC_LETTER_RE = /^[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]$/;
  }
  const stripInvisibles = (s) => s.replace(/[\u200E\u200F\u061C\u200B-\u200D\u2066-\u2069]/g, '');
  const firstAlphabetic = (s) => {
    if (!s) return '';
    const t = stripInvisibles(s).normalize('NFC');
    const m = t.match(LETTER_RE);
    return m ? m[0] : '';
  };
  const shouldBeRTL = (el) => {
    const t = el.textContent || '';
    const ch = firstAlphabetic(t);
    return ch ? ARABIC_LETTER_RE.test(ch) : false;
  };

  // ---------- Utilities ----------
  const debounce = (fn, ms = 120) => { let id; return (...a) => { clearTimeout(id); id = setTimeout(() => fn(...a), ms); }; };
  const isInContentContainer = (el) => !!el.closest(CONTENT_SELECTORS.join(','));
  const shouldSkipEl = (el) => {
    if (!TARGET_TAGS.has(el.tagName)) return true;
    if (el.closest(SKIP_ANCESTOR)) return true;
    if (el.closest('pre, code, [data-code-block], .code, .hljs')) return true;
    if (!isInContentContainer(el)) return true;
    return false;
  };

  // ---------- Observers + Work Queue ----------
  const seen = new WeakSet();
  const queue = new Set();
  let rafId = 0;

  const processEl = (el) => {
    if (!el.isConnected) return;
    if (!isOn()) { el.classList.remove(RTL_CLASS); return; }
    if (!seen.has(el)) {
      seen.add(el);
      if (shouldBeRTL(el)) el.classList.add(RTL_CLASS);
      else el.classList.remove(RTL_CLASS);
    }
  };

  const kickWork = () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      rafId = 0;
      let count = 0, MAX = 250;
      for (const el of queue) {
        queue.delete(el);
        processEl(el);
        if (++count >= MAX) break;
      }
      if (queue.size) kickWork();
    });
  };

  // Viewport observer (preloads ~600px ahead)
  const io = new IntersectionObserver((entries) => {
    if (!isOn()) return;
    for (const e of entries) if (e.isIntersecting) queue.add(e.target);
    kickWork();
  }, { root: null, rootMargin: '600px 0px', threshold: 0 });

  const observeEl = (el) => { if (!shouldSkipEl(el)) io.observe(el); };
  const observeExisting = () => {
    const rootSel = CONTENT_SELECTORS.join(',');
    document.querySelectorAll(rootSel).forEach(root => {
      root.querySelectorAll('p, li, blockquote, span').forEach(observeEl);
    });
  };

  const mo = new MutationObserver((muts) => {
    let touched = false;
    for (const m of muts) {
      if (m.type === 'childList') {
        m.addedNodes.forEach(n => {
          if (n.nodeType !== 1) return;
          if (n.matches?.(CONTENT_SELECTORS.join(','))) {
            n.querySelectorAll('p, li, blockquote, span').forEach(observeEl);
            touched = true;
          }
          n.querySelectorAll?.('p, li, blockquote, span').forEach((el) => {
            if (!shouldSkipEl(el)) { observeEl(el); touched = true; }
          });
          if (n.matches?.('p, li, blockquote, span') && !shouldSkipEl(n)) { observeEl(n); touched = true; }
        });
      } else if (m.type === 'characterData') {
        const el = m.target.parentElement?.closest?.('p, li, blockquote, span');
        if (el && !shouldSkipEl(el)) { seen.delete(el); observeEl(el); queue.add(el); touched = true; }
      }
    }
    if (touched) kickWork();
  });

  // ---------- UI (tiny pill toggle) ----------
  const ensureStyle = () => {
    if (!document.getElementById(STYLE_ID)) {
      const st = document.createElement('style');
      st.id = STYLE_ID;
      st.textContent = STYLES;
      document.head.appendChild(st);
    }
  };

  const updateUIButton = (btn) => {
    const on = isOn();
    btn.classList.toggle('on', on);
    btn.setAttribute('aria-checked', String(on));
    btn.setAttribute('title', `RTL auto-detect is ${on ? 'ON' : 'OFF'} — Ctrl+Alt+R`);
  };

  const ensureUI = () => {
    if (document.getElementById(UI_ID)) return;
    const btn = document.createElement('button');
    btn.id = UI_ID;
    btn.type = 'button';
    btn.setAttribute('role', 'switch');
    btn.setAttribute('aria-label', 'Toggle Arabic/Persian RTL auto-detect for this domain');
    btn.innerHTML = '<span class="knob" aria-hidden="true"></span>';

    updateUIButton(btn);

    const toggle = () => {
      const state = isOn() ? 'off' : 'on';
      setDomainState(state);
      updateUIButton(btn);
      if (state === 'on') refreshAll(); else clearAll();
    };

    btn.addEventListener('click', (e) => { e.preventDefault(); toggle(); });
    btn.addEventListener('keydown', (e) => {
      if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); }
    });

    document.documentElement.appendChild(btn);
  };

  // ---------- Controls ----------
  const clearAll = () => {
    queue.clear();
    document.querySelectorAll(`.${RTL_CLASS}`).forEach(el => el.classList.remove(RTL_CLASS));
  };

  const refreshAll = () => {
    queue.clear();
    observeExisting();
    kickWork();
  };

  const hookHistory = () => {
    const wrap = (fn) => function () { const r = fn.apply(this, arguments); onNav(); return r; };
    const onNav = debounce(() => { if (isOn()) refreshAll(); }, 80);
    history.pushState = wrap(history.pushState);
    history.replaceState = wrap(history.replaceState);
    window.addEventListener('popstate', onNav);
  };

  const onHotkey = (e) => {
    if (e.ctrlKey && e.altKey && (e.key === 'r' || e.key === 'R')) {
      e.preventDefault();
      const state = isOn() ? 'off' : 'on';
      setDomainState(state);
      const btn = document.getElementById(UI_ID);
      if (btn) updateUIButton(btn);
      if (state === 'on') refreshAll(); else clearAll();
    }
  };

  // ---------- Init ----------
  const init = () => {
    if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); return; }
    ensureStyle();
    ensureUI();
    observeExisting();
    mo.observe(document.body || document.documentElement, { childList: true, subtree: true, characterData: true });
    hookHistory();
    if (isOn()) kickWork();
    window.addEventListener('keydown', onHotkey, true);
  };

  init();
})();