Universal Text Reversal bypass

Reverse every selected line (Full) or (Smart). Alt+R or Tampermonkey menu. Debug mode copies result to clipboard for doing it manually if a certain site doesn't work (string looks normal but bypasses some filters)

目前為 2025-10-07 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Universal Text Reversal bypass
// @namespace    github.com/annaroblox
// @version      1.7
// @license      MIT
// @description  Reverse every selected line (Full) or (Smart).  Alt+R or Tampermonkey menu.  Debug mode copies result to clipboard for doing it manually if a certain site doesn't work (string looks normal but bypasses some filters)
// @author       AnnaRoblox
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setClipboard
// ==/UserScript==

(function () {
    'use strict';

    /* ---------- CONFIG ---------- */
    const STORAGE_KEY = 'utr_mode';
    const RLO = "\u202E";  // RIGHT-TO-LEFT OVERRIDE
    const PDF = "\u202C";  // POP DIRECTIONAL FORMATTING
    const RLI = "\u2067";  // RIGHT-TO-LEFT ISOLATE
    const PDI = "\u2069";  // POP DIRECTIONAL ISOLATE
    const LRO = "\u202D";  // LEFT-TO-RIGHT OVERRIDE

    let isDebugMode = false;
    let mode        = localStorage.getItem(STORAGE_KEY) || 'smart';

    /* ---------- CORE TEXT TRANSFORM ---------- */

    const reverseLines = text =>
        text.split('\n')
            .map(l => RLO + [...l].reverse().join('') + PDF) // Added PDF for better compatibility
            .join('\n');

    /**
     * smartreverse function
     * How it works:
     * 1. Tokenize the line into words and whitespace.
     * 2. For each word:
     *    a. Break it into pairs of characters and swap them (e.g., "test" -> "et", "ts").
     *    b. Wrap each swapped pair in RLI + RLO + ... + PDI.
     *       - RLI/PDI (Isolates) prevent the directional change from affecting other parts of the text.
     *       - RLO (Override) visually reverses the already swapped pair back to the correct order (e.g., "et" looks like "te").
     *    c. Append any leftover odd character without modification.
     * 3. Whitespace tokens are left unchanged.
     * 4. Join all the processed tokens back together.
     */
    const smartReverse = text =>
      text
        .split('\n')
        .map(line => {
          const tokens = line.match(/(\s+|\S+)/g) || [];

          const transformedTokens = tokens.map(tk => {
            // If the token is just whitespace, return it as is.
            if (/^\s+$/.test(tk)) {
              return tk;
            }

            // It's a word, so process it.
            const chars = [...tk];
            let processedWord = '';

            for (let i = 0; i < chars.length; i += 2) {
              if (i + 1 < chars.length) {
                // A pair exists. Swap the characters.
                const swappedPair = chars[i + 1] + chars[i];
                // Wrap the pair in control characters to isolate and reverse it visually.
                processedWord += RLI + RLO + swappedPair + PDI;
              } else {
                // An odd character at the end of the word. Append it directly.
                processedWord += chars[i];
              }
            }
            return processedWord;
          });

          return transformedTokens.join('');
        })
        .join('\n');

    const transform = txt => mode === 'full' ? reverseLines(txt) : smartReverse(txt);

    /* ---------- UI / MENU ---------- */
    function buildMenu() {
        GM_registerMenuCommand('Reverse selected lines', processSelection);
        GM_registerMenuCommand(
            `Mode: ${mode.toUpperCase()} (click to toggle)`,
            () => {
                mode = mode === 'full' ? 'smart' : 'full';
                localStorage.setItem(STORAGE_KEY, mode);
                buildMenu(); // Rebuild menu to show new state
            },
            'm'
        );
        GM_registerMenuCommand(
            `DEBUG: ${isDebugMode ? 'ON' : 'OFF'} (click to toggle)`,
            () => { isDebugMode = !isDebugMode; buildMenu(); },
            'd'
        );
    }

    /* ---------- CLIPBOARD HELPERS ---------- */
    function copyToClipboard(textToCopy) {
        if (typeof GM_setClipboard !== 'undefined') {
            GM_setClipboard(textToCopy);
        } else if (navigator.clipboard) {
            navigator.clipboard.writeText(textToCopy).catch(console.error);
        } else {
            const ta = document.createElement('textarea');
            ta.value = textToCopy;
            ta.style.position = 'fixed'; ta.style.left = '-9999px';
            document.body.appendChild(ta);
            ta.select();
            try { document.execCommand('copy'); } catch (e) {}
            document.body.removeChild(ta);
        }
    }

    /* ---------- IMPROVED INPUT DETECTION ---------- */
    function isEditableElement(el) {
        if (!el || !el.nodeType || el.nodeType !== 1) return false;

        const tag = el.tagName?.toLowerCase();
        if (tag === 'input' || tag === 'textarea') return true;
        if (el.contentEditable === 'true') return true;
        if (el.isContentEditable) return true;
        if (el.designMode === 'on') return true;

        // Check ARIA attributes
        const role = el.getAttribute('role');
        if (role === 'textbox' || role === 'searchbox') return true;

        return false;
    }

    function findEditableInShadowDOM(root) {
        if (!root) return null;

        if (root.activeElement && isEditableElement(root.activeElement)) {
            return root.activeElement;
        }

        if (root.activeElement?.shadowRoot) {
            const found = findEditableInShadowDOM(root.activeElement.shadowRoot);
            if (found) return found;
        }

        const selectors = [
            'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])',
            'textarea',
            '[contenteditable="true"]',
            '[role="textbox"]',
            '[role="searchbox"]'
        ];

        for (const selector of selectors) {
            const el = root.querySelector(selector);
            if (el && isEditableElement(el)) return el;
        }

        return null;
    }

    function locateRealInput(node) {
        let cur = node;

        // Traverse up including shadow DOM boundaries
        while (cur) {
            if (cur.nodeType === 1 && isEditableElement(cur)) {
                const tag = cur.tagName?.toLowerCase();
                if (tag === 'input' || tag === 'textarea') {
                    return { element: cur, type: tag };
                }
                return { element: cur, type: 'contenteditable' };
            }

            if (cur.shadowRoot) {
                const shadowEditable = findEditableInShadowDOM(cur.shadowRoot);
                if (shadowEditable) {
                    const tag = shadowEditable.tagName?.toLowerCase();
                    if (tag === 'input' || tag === 'textarea') {
                        return { element: shadowEditable, type: tag };
                    }
                    return { element: shadowEditable, type: 'contenteditable' };
                }
            }

            cur = cur.parentNode || cur.host;

            // Handle shadow root boundaries
            if (!cur && node.getRootNode) {
                const root = node.getRootNode();
                if (root?.host) cur = root.host;
            }
        }

        // Check document.activeElement chain
        let active = document.activeElement;
        while (active) {
            if (isEditableElement(active)) {
                const tag = active.tagName?.toLowerCase();
                if (tag === 'input' || tag === 'textarea') {
                    return { element: active, type: tag };
                }
                return { element: active, type: 'contenteditable' };
            }

            if (active.shadowRoot?.activeElement) {
                active = active.shadowRoot.activeElement;
            } else if (active.shadowRoot) {
                const shadowEditable = findEditableInShadowDOM(active.shadowRoot);
                if (shadowEditable) {
                    const tag = shadowEditable.tagName?.toLowerCase();
                    if (tag === 'input' || tag === 'textarea') {
                        return { element: shadowEditable, type: tag };
                    }
                    return { element: shadowEditable, type: 'contenteditable' };
                }
                break;
            } else {
                break;
            }
        }

        // Check iframes
        try {
            for (const frame of document.querySelectorAll('iframe')) {
                try {
                    const frameDoc = frame.contentDocument || frame.contentWindow?.document;
                    if (frameDoc?.activeElement && isEditableElement(frameDoc.activeElement)) {
                        const tag = frameDoc.activeElement.tagName?.toLowerCase();
                        if (tag === 'input' || tag === 'textarea') {
                            return { element: frameDoc.activeElement, type: tag };
                        }
                        return { element: frameDoc.activeElement, type: 'contenteditable' };
                    }
                } catch (e) { /* Cross-origin */ }
            }
        } catch (e) {}

        return null;
    }

    /* ---------- IMPROVED TEXT REPLACEMENT ---------- */
    function replaceTextInInput(el, type, reversed, start, end) {
        if (type === 'input' || type === 'textarea') {
            const original = el.value;
            const replacement = original.slice(0, start) + reversed + original.slice(end);

            const scrollTop = el.scrollTop;
            el.value = replacement;
            el.setSelectionRange(start, start + reversed.length);
            el.scrollTop = scrollTop;

            // Trigger events for React/Vue/Angular
            el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
            el.dispatchEvent(new Event('change', { bubbles: true }));
        } else {
            const sel = window.getSelection();

            // Try execCommand first (better undo support)
            if (document.queryCommandSupported('insertText')) {
                try {
                    if (sel.rangeCount > 0) {
                        const range = sel.getRangeAt(0);
                        if (start !== end) range.deleteContents();
                    }
                    document.execCommand('insertText', false, reversed);
                    el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
                    return;
                } catch (e) {}
            }

            // Fallback: manual insertion
            if (sel.rangeCount > 0) {
                const range = sel.getRangeAt(0);
                range.deleteContents();
                const textNode = document.createTextNode(reversed);
                range.insertNode(textNode);
                range.setStartAfter(textNode);
                range.setEndAfter(textNode);
                sel.removeAllRanges();
                sel.addRange(range);
                el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
            }
        }
    }

    function processSelection() {
        const sel = window.getSelection();
        const inputInfo = locateRealInput(sel.focusNode || document.activeElement);

        if (!inputInfo) {
            const selected = sel.toString();
            if (selected) {
                const reversed = transform(selected);
                if (isDebugMode) copyToClipboard(reversed);
                try {
                    document.execCommand('insertText', false, reversed);
                } catch (e) {
                    copyToClipboard(reversed);
                }
            }
            return;
        }

        const { element: el, type } = inputInfo;
        let original, start, end;

        if (type === 'input' || type === 'textarea') {
            original = el.value;
            start = el.selectionStart ?? 0;
            end   = el.selectionEnd ?? 0;
        } else {
            original = el.textContent || el.innerText || '';
            const range = sel.rangeCount ? sel.getRangeAt(0) : null;
            if (!range) {
                start = end = 0;
            } else {
                const pre = range.cloneRange();
                pre.selectNodeContents(el);
                pre.setEnd(range.startContainer, range.startOffset);
                start = pre.toString().length;
                end   = start + range.toString().length;
            }
        }

        const chunk = (start === end) ? original : original.slice(start, end);
        if (!chunk) return; // Do nothing if there's no text to process

        const reversed = transform(chunk);

        if (isDebugMode) copyToClipboard(reversed);

        replaceTextInInput(el, type, reversed, start, end);
    }

    /* ---------- KEYBOARD SHORTCUT ---------- */
    document.addEventListener('keydown', e => {
        if (e.altKey && e.key.toLowerCase() === 'r') {
            e.preventDefault();
            processSelection();
        }
    }, true); // Capture phase

    buildMenu();
})();