// ==UserScript==
// @name Character.AI - Message Formatting Corrector (Drag & Drop button)
// @namespace http://tampermonkey.net/
// @version 7.0
// @description Formats narration and dialogue with a single click. Features a draggable button that remembers its position and adapts for PC & Mobile.
// @author accforfaciet
// @match *://*.character.ai/chat*
// @grant GM_addStyle
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- SCRIPT SETTINGS ---
const DEBUG_MODE = false;
const ACTION_PAUSE_MS = 100;
const BUTTON_POSITION_STORAGE_KEY = 'caiFormatterButtonPosition';
// --- END OF SETTINGS ---
// --- SELECTORS ---
const MORE_OPTIONS_BUTTON_SELECTOR = 'button[aria-label="More options"]';
const EDIT_BUTTON_TEXT = 'Edit message';
const TEXT_AREA_SELECTOR = 'textarea[maxlength="4092"]';
const SAVE_BUTTON_TEXT = 'Save';
const MAIN_INPUT_SELECTOR = 'textarea[placeholder*="Message"]';
const EDITED_TAG_SELECTOR = 'p[title="Message edited by user"]';
// --- END OF SELECTORS ---
// --- HELPERS ---
function debugLog(...args) { if (DEBUG_MODE) console.log('[DEBUG]', ...args); }
function pause(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element not found: ${selector}`));
}, timeout);
});
}
function findElementByText(selector, text) {
return Array.from(document.querySelectorAll(selector)).find(el => el.textContent.trim() === text);
}
// --- CORE FORMATTING FUNCTION ---
function formatNarrationAndDialogue(text) {
const normalizedText = text.replace(/[«“”„‟⹂❞❝]/g, '"');
const lines = normalizedText.split('\n');
return lines.map(line => {
const trimmedLine = line.trim();
if (trimmedLine === '') return '';
const cleanLine = trimmedLine.replace(/\*/g, '');
if (cleanLine.includes('"') || cleanLine.includes('`')) {
const fragments = cleanLine.split(/("[\s\S]*?"|`[\s\S]*?`)/);
return fragments.map(frag => {
if ((frag.startsWith('"') && frag.endsWith('"')) || (frag.startsWith('`') && frag.endsWith('`'))) return frag;
if (frag.trim() !== '') return `*${frag.trim()}*`;
return '';
}).filter(Boolean).join(' ');
}
return `*${cleanLine}*`;
}).join('\n');
}
// --- MESSAGE CLEANUP LOGIC ---
/** A reusable function that cleans a single message given its "(edited)" tag element. */
function performCleanupOnTag(editedTag) {
const messageContainer = editedTag.closest('div[class*="border-dashed"]');
if (messageContainer) {
messageContainer.classList.remove('border-dashed', 'border-2', 'border-blue', 'border-opacity-35');
debugLog('Removed border styles from a message.');
}
const tagContainer = editedTag.parentElement;
if (tagContainer) {
tagContainer.remove();
debugLog('Removed an (edited) tag element.');
}
}
/** --- NEW: Cleans up all pre-existing edited messages on page load --- */
async function cleanupAllExistingMessages() {
try {
// Wait for the chat to be loaded by looking for the first message options button
await waitForElement(MORE_OPTIONS_BUTTON_SELECTOR);
await pause(500); // A brief extra pause for content to settle
const allEditedTags = document.querySelectorAll(EDITED_TAG_SELECTOR);
if (allEditedTags.length > 0) {
debugLog(`Found ${allEditedTags.length} pre-existing edited messages. Cleaning them up...`);
allEditedTags.forEach(performCleanupOnTag);
} else {
debugLog('No pre-existing edited messages found on startup.');
}
} catch (error) {
console.error("Could not perform initial cleanup (this is okay if there's no chat loaded):", error);
}
}
// --- MAIN SCRIPT LOGIC ---
async function processLastMessage(textProcessor) {
debugLog('--- STARTING C.AI EDIT PROCESS ---');
try {
const latestOptionsButton = document.querySelector(MORE_OPTIONS_BUTTON_SELECTOR);
if (!latestOptionsButton) {
debugLog('STOP: No "More options" buttons found.'); return;
}
latestOptionsButton.click();
debugLog('1. Clicked "More options" button.');
await pause(ACTION_PAUSE_MS);
const editButton = findElementByText('button', EDIT_BUTTON_TEXT);
if (!editButton) {
debugLog('STOP: Could not find "Edit message" button.');
latestOptionsButton.click(); // Close the menu even if it fails
return;
}
editButton.click();
latestOptionsButton.click(); // Close the menu
debugLog('2. Clicked "Edit message" and closed menu.');
await pause(ACTION_PAUSE_MS);
const textField = await waitForElement(TEXT_AREA_SELECTOR);
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
nativeSetter.call(textField, textProcessor(textField.value));
textField.dispatchEvent(new Event('input', { bubbles: true }));
debugLog('3. Injected new text into textarea.');
const saveButton = findElementByText('button', SAVE_BUTTON_TEXT);
if (!saveButton) {
debugLog('STOP: Could not find "Save" button.'); return;
}
saveButton.click();
debugLog('4. Clicked "Save". Setting up observer for next message cleanup...');
cleanupAllExistingMessages()
debugLog('--- PROCESS SUCCESSFULLY COMPLETED ---');
} catch (error) {
console.error('CRITICAL ERROR during the C.AI editing process:', error);
}
}
// --- DRAGGABLE BUTTON LOGIC (Unchanged) ---
function makeDraggable(element) {
let isDragging = false, hasDragged = false, startX, startY, initialLeft, initialTop;
function dragStart(e) {
isDragging = true; hasDragged = false;
element.classList.add('is-dragging');
const clientX = e.clientX ?? e.touches[0].clientX, clientY = e.clientY ?? e.touches[0].clientY;
startX = clientX; startY = clientY;
const rect = element.getBoundingClientRect();
initialLeft = rect.left; initialTop = rect.top;
window.addEventListener('mousemove', dragMove, { passive: false });
window.addEventListener('touchmove', dragMove, { passive: false });
window.addEventListener('mouseup', dragEnd);
window.addEventListener('touchend', dragEnd);
}
function dragMove(e) {
if (!isDragging) return;
e.preventDefault(); hasDragged = true;
const clientX = e.clientX ?? e.touches[0].clientX, clientY = e.clientY ?? e.touches[0].clientY;
let newLeft = initialLeft + (clientX - startX), newTop = initialTop + (clientY - startY);
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - element.offsetWidth));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - element.offsetHeight));
element.style.cssText += `right:auto; bottom:auto; left:${newLeft}px; top:${newTop}px;`;
}
function dragEnd() {
if (!isDragging) return;
isDragging = false;
element.classList.remove('is-dragging');
if (hasDragged) savePosition({ left: element.getBoundingClientRect().left, top: element.getBoundingClientRect().top });
window.removeEventListener('mousemove', dragMove);
window.removeEventListener('touchmove', dragMove);
window.removeEventListener('mouseup', dragEnd);
window.removeEventListener('touchend', dragEnd);
}
element.addEventListener('mousedown', dragStart);
element.addEventListener('touchstart', dragStart, { passive: true });
element.addEventListener('click', () => { if (!hasDragged) processLastMessage(formatNarrationAndDialogue); });
}
function savePosition(pos) { localStorage.setItem(BUTTON_POSITION_STORAGE_KEY, JSON.stringify(pos)); }
function loadPosition(element) {
const savedPos = localStorage.getItem(BUTTON_POSITION_STORAGE_KEY);
if (savedPos) {
const pos = JSON.parse(savedPos);
element.style.cssText += `right:auto; bottom:auto; left:${pos.left}px; top:${pos.top}px;`;
}
}
// --- UI CREATION & INITIALIZATION ---
function createTriggerButton() {
const formatButton = document.createElement('button');
formatButton.innerHTML = '✏️';
formatButton.id = 'cai-formatter-trigger';
formatButton.title = 'Click to format message. Hold and drag to move.';
document.body.appendChild(formatButton);
loadPosition(formatButton);
makeDraggable(formatButton);
}
async function initKeyboardBugFix() {
try {
const mainInput = await waitForElement(MAIN_INPUT_SELECTOR);
const button = document.getElementById('cai-formatter-trigger');
if (mainInput && button) {
mainInput.addEventListener('focus', () => { button.style.display = 'none'; });
mainInput.addEventListener('blur', () => { setTimeout(() => { button.style.display = 'block'; }, 200); });
}
} catch (e) { console.log('Could not find main input for keyboard fix.'); }
}
// --- ADAPTIVE STYLES (Unchanged) ---
GM_addStyle(`
#cai-formatter-trigger {
position: fixed; z-index: 9999; color: white; border: none;
border-radius: 50%; box-shadow: 0 4px 8px rgba(0,0,0,0.3);
cursor: pointer; transition: transform 0.2s, opacity 0.2s;
background-color: #1A73E8; /* Character.AI Blue */
user-select: none;
}
#cai-formatter-trigger:active { cursor: grabbing; }
#cai-formatter-trigger.is-dragging {
transform: scale(1.1); opacity: 0.8;
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
#cai-formatter-trigger { width: 45px; height: 45px; font-size: 20px; right: 5%; bottom: 15%; }
@media (min-width: 769px) {
#cai-formatter-trigger { width: 50px; height: 50px; font-size: 24px; right: 2%; bottom: 12%; }
}
`);
// --- STARTUP ---
createTriggerButton();
initKeyboardBugFix();
cleanupAllExistingMessages(); // Run the new cleanup function on startup
console.log('Script "Character.AI - Message Formatting Corrector" (v7.0) started successfully.');
})();