Universal Text Reversal

Reverse every selected line (or the whole field) in ANY editable element – even inside shadow-roots. Alt+R or Tampermonkey menu. Debug mode copies the result to the clipboard.

// ==UserScript==
// @name Universal Text Reversal 
// @namespace http://tampermonkey.net/
// @version 1.0
// @license MIT
// @description Reverse every selected line (or the whole field) in ANY editable element – even inside shadow-roots. Alt+R or Tampermonkey menu. Debug mode copies the result to the clipboard.
// @author AnnaRoblox
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// ==/UserScript==

(function () {
    'use strict';

    /* ---------- Debug Mode Configuration ---------- */
    // Change this to true to have debug mode enabled by default.
    let isDebugMode = true;

    /* ---------- Helpers ---------- */

    const RTL_MAGIC = "‬‭‮‬‭‮   ";

    /**
     * Reverses each line of the provided text and adds a special magic string.
     * @param {string} text
     * @returns {string}
     */
    const reverseLines = text =>
        text
            .split('\n')
            .map(l => RTL_MAGIC + [...l].reverse().join(''))
            .join('\n');

    /**
     * Get the real “thing you type into” that is associated with a node.
     * Walks up the light DOM *and* open shadow DOM trees.
     * @param {Node} node
     * @returns {{element:HTMLElement, type:'input'|'textarea'|'contenteditable'|'shadowInput'}|null}
     */
    function locateRealInput(node) {
        let cur = node;
        while (cur && cur !== document.documentElement) {
            if (cur.nodeType !== 1) { cur = cur.parentNode; continue; }

            // classic form controls
            if (cur.tagName === 'INPUT' || cur.tagName === 'TEXTAREA')
                return { element: cur, type: cur.tagName.toLowerCase() };

            // simple contenteditable
            if (cur.contentEditable === 'true')
                return { element: cur, type: 'contenteditable' };

            // dive into open shadow roots (e.g. Monaco, CodeMirror-v6, custom elements)
            if (cur.shadowRoot) {
                const sr = cur.shadowRoot;
                const active = sr.activeElement || sr.querySelector('input, textarea, [contenteditable="true"]');
                if (active) return locateRealInput(active);
            }

            cur = cur.parentNode || cur.host; // .host jumps out of shadow root
        }
        return null;
    }

    /**
     * Copies the given text to the clipboard using the most modern method available.
     * @param {string} textToCopy
     */
    function copyToClipboard(textToCopy) {
        if (typeof GM_setClipboard !== 'undefined') {
            GM_setClipboard(textToCopy);
        } else if (navigator.clipboard) {
            navigator.clipboard.writeText(textToCopy).catch(err => {
                console.error('Could not copy text to clipboard using modern API: ', err);
            });
        } else {
            // Fallback for older browsers
            const textarea = document.createElement('textarea');
            textarea.value = textToCopy;
            textarea.style.position = 'absolute';
            textarea.style.left = '-9999px';
            document.body.appendChild(textarea);
            textarea.select();
            try {
                document.execCommand('copy');
            } catch (err) {
                console.error('Could not copy text to clipboard using execCommand: ', err);
            } finally {
                document.body.removeChild(textarea);
            }
        }
    }


    /* ---------- Core Logic ---------- */

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

        if (!inputInfo) {
            // Nothing editable found – treat as plain text on the page
            const selected = sel.toString();
            if (selected) {
                const reversed = reverseLines(selected);
                if (isDebugMode) {
                    copyToClipboard(reversed);
                }
                document.execCommand('insertText', false, reversed);
            }
            return;
        }

        const { element: el, type } = inputInfo;

        let original, start, end;

        /* 1.  Read original text + caret/selection positions */
        if (type === 'input' || type === 'textarea') {
            original = el.value;
            start = el.selectionStart;
            end = el.selectionEnd;
        } else if (type === 'contenteditable' || type === 'shadowInput') {
            original = el.textContent || '';
            // Map DOM selection to plain-text offsets
            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;
            }
        }

        /* 2.  Reverse the chosen chunk */
        const chunk = (start === end) ? original : original.slice(start, end);
        const reversed = reverseLines(chunk);
        const replacement =
            start === end
                ? reversed
                : original.slice(0, start) + reversed + original.slice(end);

        /* 3.  If debug mode is on, copy the reversed text to clipboard */
        if (isDebugMode) {
            copyToClipboard(reversed);
        }

        /* 4.  Write back to the element */
        if (type === 'input' || type === 'textarea') {
            el.value = replacement;
            el.setSelectionRange(start, start + reversed.length);
        } else {
            // contenteditable or shadow
            el.textContent = replacement;
            // restore selection
            const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
            let node, offset = 0, startNode, endNode;
            while (node = walker.nextNode()) {
                const len = node.textContent.length;
                if (!startNode && offset + len >= start) {
                    startNode = node;
                    const range = new Range();
                    range.setStart(startNode, start - offset);
                    range.setEnd(startNode, start - offset + reversed.length);
                    sel.removeAllRanges();
                    sel.addRange(range);
                    break;
                }
                offset += len;
            }
        }
    }


    /* ---------- Keyboard & Tampermonkey Menu ---------- */

    // Keyboard shortcut (Alt+R)
    document.addEventListener('keydown', e => {
        if (e.altKey && e.key.toLowerCase() === 'r') {
            e.preventDefault();
            processSelection();
        }
    });

    // Register Tampermonkey menu command for the main function
    GM_registerMenuCommand('Reverse selected lines', processSelection);

    // Function to toggle debug mode and update the menu command
    function toggleDebugMode() {
        isDebugMode = !isDebugMode;
        updateMenuCommands();
    }

    // Register Tampermonkey menu command for debug mode
    function updateMenuCommands() {
        // Clear existing debug menu commands
        GM_registerMenuCommand('DEBUG: ' + (isDebugMode ? 'ON' : 'OFF'), toggleDebugMode);
    }

    // Initial registration of the menu command
    updateMenuCommands();

})();