ChatGPT-ComposerFormatGuard

Keep pasted and typed Markdown literal in the ChatGPT composer until it is sent.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name			ChatGPT-ComposerFormatGuard
// @name:zh-CN		ChatGPT-输入格式保护
// @namespace		https://github.com/Mehver
// @version		1.0
// @description		Keep pasted and typed Markdown literal in the ChatGPT composer until it is sent.
// @description:zh-CN	让粘贴和键入的 Markdown 在 ChatGPT 输入框中保持原样,发送后再由 ChatGPT 渲染。
// @author		https://github.com/Mehver
// @icon			data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IB2cksfwAAAAlwSFlzAAAuIwAALiMBeKU/dgAAA1RpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiI+CjxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CjxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOklwdGM0eG1wQ29yZT0iaHR0cDovL2lwdGMub3JnL3N0ZC9JcHRjNHhtcENvcmUvMS4wL3htbG5zLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI0MDAwIiBleGlmOlBpeGVsWURpbWVuc2lvbj0iNDAwMCI+CjwvcmRmOkRlc2NyaXB0aW9uPgo8L3JkZjpSREY+CjwveDp4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
// @match		https://chatgpt.com/*
// @match		https://chat.openai.com/*
// @license		BSD-3-Clause
// @charset		UTF-8
// @homepageURL		https://github.com/MehverSynRGB/ChatGPT-ComposerFormatGuard
// @contributionURL	https://github.com/MehverSynRGB/ChatGPT-ComposerFormatGuard
// @copyright		Copyright (c) 2026 Mehver (https://github.com/Mehver). All rights reserved.
// @grant			GM_getValue
// @grant			GM_setValue
// @run-at		document-start
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    const COMPOSER_SELECTOR = '#thread-bottom form[data-type="unified-composer"] #prompt-textarea.ProseMirror[contenteditable="true"], form[data-type="unified-composer"] #prompt-textarea.ProseMirror[contenteditable="true"], #prompt-textarea.ProseMirror[contenteditable="true"]';
    const STORAGE_KEY = 'chatgptComposerFormatGuardEnabled';
    const TOGGLE_ID = 'chatgpt-composer-format-guard-toggle';
    const FORMAT_SHORTCUTS = new Set(['b', 'i', 'u']);
    const LIST_INPUT_TYPES = new Set(['insertOrderedList', 'insertUnorderedList']);
    const HTML_BLOCK_TAGS = new Set(['ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'TD', 'TH', 'TR', 'UL']);
    const HTML_IGNORED_TAGS = new Set(['EMBED', 'IFRAME', 'NOSCRIPT', 'OBJECT', 'SCRIPT', 'STYLE', 'TEMPLATE', 'TITLE']);

    let enabled = getEnabledPreference();
    let composing = false;

    function getComposer(target) {
        return target instanceof Element ? target.closest(COMPOSER_SELECTOR) : null;
    }

    function containsFile(clipboardData) {
        return Array.from(clipboardData.items || []).some(item => item.kind === 'file');
    }

    function getClipboardText(clipboardData) {
        const plainText = clipboardData.getData('text/plain');
        if (plainText) return plainText;

        const html = clipboardData.getData('text/html');
        if (!html) return '';

        const parsed = new DOMParser().parseFromString(html, 'text/html');
        const text = [];

        function appendBreak() {
            if (text.length && !text[text.length - 1].endsWith('\n')) text.push('\n');
        }

        function isBlockBoundary(node) {
            return node && node.nodeType === 1 && (node.tagName === 'BR' || HTML_BLOCK_TAGS.has(node.tagName));
        }

        function readNode(node) {
            if (node.nodeType === 3) {
                const value = node.nodeValue || '';
                const previous = node.previousSibling;
                const next = node.nextSibling;
                const isPreformatted = node.parentElement && node.parentElement.closest('pre, textarea');
                if (!value.trim() && !isPreformatted) {
                    if (isBlockBoundary(previous) || isBlockBoundary(next)) return;
                    text.push(/[\r\n\t]/.test(value) ? ' ' : value);
                    return;
                }

                text.push(value);
                return;
            }
            if (node.nodeType !== 1) return;
            if (HTML_IGNORED_TAGS.has(node.tagName)) return;

            if (node.tagName === 'BR') {
                text.push('\n');
                return;
            }

            const isBlock = HTML_BLOCK_TAGS.has(node.tagName);
            if (isBlock) appendBreak();
            for (const child of node.childNodes) readNode(child);
            if (isBlock) appendBreak();
        }

        readNode(parsed.body);
        return text.join('').replace(/^\n+|\n+$/g, '');
    }

    function getEnabledPreference() {
        try {
            if (typeof GM_getValue === 'function') return GM_getValue(STORAGE_KEY, true) !== false;
        } catch (error) {
            console.warn('[ChatGPT-ComposerFormatGuard] Unable to read the saved setting.', error);
        }

        return localStorage.getItem(STORAGE_KEY) !== 'false';
    }

    function setEnabledPreference(value) {
        enabled = value;

        try {
            if (typeof GM_setValue === 'function') {
                GM_setValue(STORAGE_KEY, value);
            } else {
                localStorage.setItem(STORAGE_KEY, String(value));
            }
        } catch (error) {
            console.warn('[ChatGPT-ComposerFormatGuard] Unable to save the setting.', error);
        }

        updateToggle();
    }

    function createPlainTextPasteEvent(text) {
        try {
            const rawClipboard = new DataTransfer();
            rawClipboard.setData('text/plain', text.replace(/\r\n?/g, '\n'));
            return new ClipboardEvent('paste', {
                bubbles: true,
                cancelable: true,
                composed: true,
                clipboardData: rawClipboard
            });
        } catch (error) {
            console.warn('[ChatGPT-ComposerFormatGuard] Unable to create a plain-text paste event.', error);
            return null;
        }
    }

    document.addEventListener('paste', event => {
        // Ignore our replacement event and leave attachments for ChatGPT to handle.
        if (!enabled || composing || !event.isTrusted || event.defaultPrevented) return;

        const composer = getComposer(event.target);
        const clipboardData = event.clipboardData;
        if (!composer || !clipboardData || containsFile(clipboardData)) return;

        const plainText = getClipboardText(clipboardData);
        if (!plainText) return;

        const rawPasteEvent = createPlainTextPasteEvent(plainText);
        if (!rawPasteEvent) return;

        event.preventDefault();
        event.stopImmediatePropagation();
        composer.dispatchEvent(rawPasteEvent);
    }, true);

    document.addEventListener('beforeinput', event => {
        if (!enabled || composing || !event.isTrusted || event.defaultPrevented || event.isComposing) return;

        const composer = getComposer(event.target);
        if (!composer) return;

        const inputType = event.inputType || '';
        if (inputType === 'insertText' && event.data) {
            const rawPasteEvent = createPlainTextPasteEvent(event.data);
            if (!rawPasteEvent) return;

            event.preventDefault();
            event.stopImmediatePropagation();
            composer.dispatchEvent(rawPasteEvent);
            return;
        }

        if ((inputType.startsWith('format') && inputType !== 'formatSetBlockTextDirection') || LIST_INPUT_TYPES.has(inputType)) {
            event.preventDefault();
            event.stopImmediatePropagation();
        }
    }, true);

    document.addEventListener('keydown', event => {
        if (!enabled || composing || !event.isTrusted || event.defaultPrevented || event.isComposing) return;
        if ((!event.ctrlKey && !event.metaKey) || event.altKey || !FORMAT_SHORTCUTS.has(event.key.toLowerCase())) return;
        if (!getComposer(event.target)) return;

        event.preventDefault();
        event.stopImmediatePropagation();
    }, true);

    document.addEventListener('compositionstart', event => {
        if (getComposer(event.target)) composing = true;
    }, true);

    document.addEventListener('compositionend', event => {
        if (!getComposer(event.target)) return;

        // Keep the final IME commit on the native path even when the browser clears isComposing early.
        window.setTimeout(() => {
            composing = false;
        }, 0);
    }, true);

    function updateToggle() {
        const toggle = document.getElementById(TOGGLE_ID);
        if (!toggle) return;

        toggle.style.background = enabled ? '#10a37f' : '#6e6e80';
        toggle.style.boxShadow = enabled ? '0 2px 10px rgba(16, 163, 127, 0.42)' : '0 2px 10px rgba(0, 0, 0, 0.24)';
        toggle.style.opacity = enabled ? '1' : '0.72';
        toggle.setAttribute('aria-pressed', String(enabled));
        toggle.setAttribute('aria-label', `输入格式保护:${enabled ? '已启用,点击关闭' : '已关闭,点击启用'}`);
        toggle.title = `输入格式保护:${enabled ? '已启用' : '已关闭'}`;
    }

    function mountToggle() {
        if (document.getElementById(TOGGLE_ID)) return true;
        if (!document.querySelector(COMPOSER_SELECTOR) || !document.body) return false;

        const toggle = document.createElement('button');
        toggle.id = TOGGLE_ID;
        toggle.type = 'button';
        toggle.textContent = 'MD';
        toggle.style.cssText = 'all:unset;box-sizing:border-box;position:fixed;right:20px;bottom:96px;z-index:1000;display:flex;align-items:center;justify-content:center;width:42px;height:42px;border:1px solid rgba(255,255,255,0.28);border-radius:50%;color:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;font-weight:700;letter-spacing:-0.5px;cursor:pointer;user-select:none;transition:transform 0.15s ease,opacity 0.15s ease,background 0.15s ease,box-shadow 0.15s ease;';
        toggle.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            setEnabledPreference(!enabled);
        });
        toggle.addEventListener('mouseenter', () => {
            toggle.style.transform = 'scale(1.08)';
        });
        toggle.addEventListener('mouseleave', () => {
            toggle.style.transform = '';
        });

        document.body.appendChild(toggle);
        updateToggle();
        return true;
    }

    function installToggle() {
        mountToggle();

        let scheduled = false;
        const observer = new MutationObserver(() => {
            if (document.getElementById(TOGGLE_ID) || scheduled) return;

            scheduled = true;
            window.requestAnimationFrame(() => {
                scheduled = false;
                mountToggle();
            });
        });
        observer.observe(document.documentElement, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', installToggle, { once: true });
    } else {
        installToggle();
    }

    console.log('Script ChatGPT-ComposerFormatGuard (ChatGPT-输入格式保护) loaded successfully. https://github.com/MehverSynRGB/ChatGPT-ComposerFormatGuard');
})();