|
// ==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(); |
|
} |
|
})(); |