Skip to content

Instantly share code, notes, and snippets.

@MusiCode1
Created June 1, 2026 11:54
Show Gist options
  • Select an option

  • Save MusiCode1/ef5bfffe327128be5ce9b8b4741a27e1 to your computer and use it in GitHub Desktop.

Select an option

Save MusiCode1/ef5bfffe327128be5ce9b8b4741a27e1 to your computer and use it in GitHub Desktop.
Set RTL — כפיית RTL ויישור לימין על אתרים שלא תומכים בעברית (Tampermonkey, loop-safe, Shadow DOM)

Set RTL — כפיית כיווניות ימין-לשמאל על אתרים

סקריפט Tampermonkey שמכריח RTL ויישור לימין על אתרים שלא תומכים בעברית/ערבית כמו שצריך — אתרים שלא אתה כתבת ואין לך דרך לתקן מבפנים.

מה זה עושה

על כל אלמנט שמכיל טקסט (וגם על שדות קלט, textarea, ו-contenteditable) הסקריפט מוסיף:

  • dir="auto" — מאפשר לדפדפן לזהות אוטומטית את כיוון הטקסט (עברית → ימין, אנגלית → שמאל).
  • text-align: right !important — מיישר את הטקסט לימין.

זה עובד גם על תוכן שנטען דינמית אחרי טעינת הדף (דרך MutationObserver), וגם על תוכן בתוך Shadow DOM — כולל shadow roots מסוג closed — דרך עטיפה של attachShadow.

איך משתמשים

  1. התקן Tampermonkey בדפדפן.
  2. צור סקריפט חדש והדבק את set-rtl.user.js.
  3. ערוך את שורות ה-@match לפי האתרים שאתה רוצה (כרגע תופס *://*/* — כלומר כל אתר).

הערות

  • תופס את כל האתרים (@match *://*/*). אם זה מכביד על אתר מסוים, הוסף לו @exclude.
  • iframes: אם תרצה שיעבוד גם בתוך מסגרות מוטמעות, הוסף // @allFrames ל-header.
  • הסקריפט בטוח מלולאות — ההסבר הטכני המלא (ולמה אסור "לתקן" אותו בדרכים מסוימות) נמצא בבלוק DESIGN NOTES בראש הקוד.
// ==UserScript==
// @name Set RTL
// @namespace http://tampermonkey.net/
// @version 2026-06-01
// @description Force RTL + right alignment (with Shadow DOM support, loop-safe)
// @author You
// @match http://192.168.33.42:4096/*
// @match http://192.168.33.45:4096/*
// @match *://*/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=opencode.local
// @grant none
// @run-at document-start
// ==/UserScript==
// -----------------------------------------------------------------------------
// DESIGN NOTES — why this script is loop-safe (read before "optimizing")
// -----------------------------------------------------------------------------
//
// History: an earlier version of this script FROZE browsers. Three causes:
// 1. Infinite re-entry loop — the MutationObserver watched `attributes`, and
// the script's own dir/style writes re-triggered the observer → loop.
// 2. O(n^2) work — querySelectorAll('*') ran synchronously on every added
// node, on every mutation.
// 3. A global attachShadow monkey-patch with no try/catch, no double-patch
// guard, no closed-mode handling.
//
// KEY DECISION 1 — the loop is closed by the attributeFilter, NOT by guards.
// The observer uses attributeFilter: ['contenteditable','type']. The ONLY
// things this script writes are `dir` (setAttribute) and `text-align` (inline
// style → the `style` attribute). Neither `dir` nor `style` is in the filter,
// so the engine produces ZERO mutation records for our writes. Our writes are
// structurally incapable of waking the observer. This is an engine-level
// guarantee, not luck.
//
// KEY DECISION 2 — we deliberately DO NOT disconnect the observer while writing.
// An earlier rewrite wrapped writes in disconnect()/takeRecords()/reobserve()
// as "belt and suspenders". That was not just redundant (decision 1 already
// closes the loop) — it was HARMFUL:
// (a) While disconnected, DOM changes the host app makes are never recorded
// → those elements silently never get RTL (lost-mutation window).
// (b) reobserveAll() required a strong Set of every root ever seen
// (watchedRoots), which only grew → memory leak + per-flush CPU
// proportional to all dead roots.
// Removing the disconnect machinery fixed both. observedRoots stays a WeakSet
// (prevents double-observe, lets dead roots be GC'd). Do NOT reintroduce a
// disconnect/reobserve cycle.
//
// KEY DECISION 3 — idempotency + batching are defense-in-depth, not the fix.
// applyStyles() checks the current value before writing; processedEls
// (WeakSet) skips already-styled elements; all work is coalesced into one
// requestIdleCallback flush. These reduce work but are NOT what prevents the
// loop — decision 1 is.
//
// KEY DECISION 4 — attachShadow patch handles open AND closed roots.
// We capture the returned shadowRoot ref (works even when el.shadowRoot is
// null for closed mode), wrap observation in try/catch so we never break the
// host app, and guard against double-patching (__rtlPatched + a per-realm
// window.__rtlScriptInstalled flag).
// -----------------------------------------------------------------------------
(function () {
'use strict';
// Guard against double-injection (iframe / re-injection): if we already
// patched in this realm, bail out entirely.
if (window.__rtlScriptInstalled) return;
window.__rtlScriptInstalled = true;
const observedRoots = new WeakSet();
const processedEls = new WeakSet(); // elements we've already styled
const IGNORED_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT']);
// ----- batching -----
const pendingEls = new Set(); // elements waiting to be (re)processed
const pendingRoots = new Set(); // roots waiting to be fully scanned
let flushScheduled = false;
const scheduleIdle =
typeof window.requestIdleCallback === 'function'
? (cb) => window.requestIdleCallback(cb, { timeout: 250 })
: (cb) => setTimeout(cb, 16);
function scheduleFlush() {
if (flushScheduled) return;
flushScheduled = true;
scheduleIdle(flush);
}
// ----- helpers -----
function hasDirectText(element) {
if (!element || !element.childNodes) return false;
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue && node.nodeValue.trim().length > 0) {
return true;
}
}
return false;
}
function shouldProcess(el) {
if (!el || el.nodeType !== Node.ELEMENT_NODE || IGNORED_TAGS.has(el.tagName)) return false;
const isStandardInput =
(el.tagName === 'INPUT' && el.type !== 'hidden') || el.tagName === 'TEXTAREA';
const isFancyInput = el.isContentEditable;
const hasText = hasDirectText(el);
return isStandardInput || isFancyInput || hasText;
}
// Returns true if it actually changed something (i.e. produced mutations).
function applyStyles(el) {
let changed = false;
if (el.getAttribute('dir') !== 'auto') {
el.setAttribute('dir', 'auto');
changed = true;
}
// Compare against the inline value to avoid pointless rewrites.
if (el.style.getPropertyValue('text-align') !== 'right') {
el.style.setProperty('text-align', 'right', 'important');
changed = true;
}
return changed;
}
// ----- flush: the ONLY place that writes to the DOM -----
function flush() {
flushScheduled = false;
// First, expand any roots that need a full scan into individual elements.
if (pendingRoots.size) {
const roots = Array.from(pendingRoots);
pendingRoots.clear();
for (const root of roots) collectFromRoot(root);
}
if (!pendingEls.size) return;
const els = Array.from(pendingEls);
pendingEls.clear();
// We DON'T disconnect the observer here. Our only writes are `dir` and
// inline `style` (text-align). The observer's attributeFilter is
// ['contenteditable','type'], so neither write produces a mutation record
// — the engine guarantees our writes can't re-enter the observer. Keeping
// the observer connected also means we never miss DOM changes the host app
// makes while we're processing.
for (const el of els) {
if (!el.isConnected) continue;
if (!shouldProcess(el)) continue;
applyStyles(el);
processedEls.add(el);
}
}
// ----- queueing (no DOM writes here) -----
function queueElement(el) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return;
pendingEls.add(el);
scheduleFlush();
}
function queueRoot(root) {
if (!root) return;
pendingRoots.add(root);
scheduleFlush();
}
// Walk a root and queue every relevant element. Also discovers nested
// shadow roots and starts observing them.
function collectFromRoot(root) {
if (root.nodeType === Node.ELEMENT_NODE && !processedEls.has(root)) {
pendingEls.add(root);
}
if (!root.querySelectorAll) return;
root.querySelectorAll('*').forEach((el) => {
if (!processedEls.has(el)) pendingEls.add(el);
if (el.shadowRoot) observeRoot(el.shadowRoot);
});
}
// ----- observer -----
const OBSERVE_OPTS = {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['contenteditable', 'type'],
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.target?.nodeType === Node.ELEMENT_NODE) {
// A framework changed contenteditable/type — re-evaluate this element.
processedEls.delete(mutation.target);
queueElement(mutation.target);
}
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
queueRoot(node);
if (node.shadowRoot) observeRoot(node.shadowRoot);
}
}
});
function observeRoot(root) {
if (!root || observedRoots.has(root)) return;
// observedRoots is a WeakSet: it only prevents double-observing the same
// root, and lets detached roots be garbage-collected (no strong refs held).
observedRoots.add(root);
queueRoot(root);
observer.observe(root, OBSERVE_OPTS);
}
// ----- attachShadow patch (open + closed), hardened -----
const nativeAttachShadow = Element.prototype.attachShadow;
if (typeof nativeAttachShadow === 'function' && !nativeAttachShadow.__rtlPatched) {
const patched = function (init) {
// Let the native call (and any throw) happen normally.
const shadowRoot = nativeAttachShadow.call(this, init);
// Works for both 'open' and 'closed' — we hold the returned root
// reference even when el.shadowRoot stays null (closed mode).
try {
queueMicrotask(() => {
try {
observeRoot(shadowRoot);
} catch (e) {
// Never let our observation break the host app.
console.warn('[RTL] failed to observe shadow root:', e);
}
});
} catch (e) {
console.warn('[RTL] failed to schedule shadow observe:', e);
}
return shadowRoot;
};
patched.__rtlPatched = true;
Element.prototype.attachShadow = patched;
}
// ----- init -----
function init() {
try {
observeRoot(document.documentElement);
document.querySelectorAll('*').forEach((el) => {
if (el.shadowRoot) observeRoot(el.shadowRoot);
});
console.log('[RTL] RTL + Shadow DOM support enabled (loop-safe).');
} catch (e) {
console.warn('[RTL] init failed:', e);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment