您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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(); })();