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