Character.AI - Message Formatting Corrector (Drag & Drop button)

Formats narration and dialogue with a single click. Features a draggable button that remembers its position and adapts for PC & Mobile.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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