Copy HTML formatting into Unicode Supported Formatting

Convert HTML formatting to Unicode characters when copying text from AI chat websites

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

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

คุณจะต้องติดตั้งส่วนขยาย เช่น 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         Copy HTML formatting into Unicode Supported Formatting
// @namespace    www.fiverr.com/web_coder_nsd
// @version      1.0.8
// @description  Convert HTML formatting to Unicode characters when copying text from AI chat websites
// @author       noushadBug
// @license      MIT
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://deepseek.com/*
// @match        https://chat.deepseek.com/*
// @match        https://gemini.google.com/*
// @match        https://z.ai/*
// @match        https://chat.z.ai/*
// @match        https://claude.ai/*
// @match        https://perplexity.ai/*
// @match        https://www.perplexity.ai/*
// @match        https://poe.com/*
// @match        https://copilot.microsoft.com/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    const IS_CHATGPT = /chatgpt\.com|chat\.openai\.com/.test(location.hostname);

    // ═══════════════════════════════════════════════════════════════
    // UNICODE CHARACTER MAPPINGS
    // ═══════════════════════════════════════════════════════════════

    const BOLD = {
        'A':'𝗔','B':'𝗕','C':'𝗖','D':'𝗗','E':'𝗘','F':'𝗙','G':'𝗚','H':'𝗛','I':'𝗜','J':'𝗝',
        'K':'𝗞','L':'𝗟','M':'𝗠','N':'𝗡','O':'𝗢','P':'𝗣','Q':'𝗤','R':'𝗥','S':'𝗦','T':'𝗧',
        'U':'𝗨','V':'𝗩','W':'𝗪','X':'𝗫','Y':'𝗬','Z':'𝗭',
        'a':'𝗮','b':'𝗯','c':'𝗰','d':'𝗱','e':'𝗲','f':'𝗳','g':'𝗴','h':'𝗵','i':'𝗶','j':'𝗷',
        'k':'𝗸','l':'𝗹','m':'𝗺','n':'𝗻','o':'𝗼','p':'𝗽','q':'𝗾','r':'𝗿','s':'𝘀','t':'𝘁',
        'u':'𝘂','v':'𝘃','w':'𝘄','x':'𝘅','y':'𝘆','z':'𝘇',
        '0':'𝟬','1':'𝟭','2':'𝟮','3':'𝟯','4':'𝟰','5':'𝟱','6':'𝟲','7':'𝟳','8':'𝟴','9':'𝟵'
    };
    const ITALIC = {
        'A':'𝘈','B':'𝘉','C':'𝘊','D':'𝘋','E':'𝘌','F':'𝘍','G':'𝘎','H':'𝘏','I':'𝘐','J':'𝘑',
        'K':'𝘒','L':'𝘓','M':'𝘔','N':'𝘕','O':'𝘖','P':'𝘗','Q':'𝘘','R':'𝘙','S':'𝘚','T':'𝘛',
        'U':'𝘜','V':'𝘝','W':'𝘞','X':'𝘟','Y':'𝘠','Z':'𝘡',
        'a':'𝘢','b':'𝘣','c':'𝘤','d':'𝘥','e':'𝘦','f':'𝘧','g':'𝘨','h':'𝗁','i':'𝘪','j':'𝘫',
        'k':'𝘬','l':'𝘭','m':'𝘮','n':'𝘯','o':'𝘰','p':'𝘱','q':'𝘲','r':'𝘳','s':'𝘴','t':'𝘵',
        'u':'𝘶','v':'𝘷','w':'𝘸','x':'𝘹','y':'𝘺','z':'𝘻'
    };
    const MONO = {
        'A':'𝙰','B':'𝙱','C':'𝙲','D':'𝙳','E':'𝙴','F':'𝙵','G':'𝙶','H':'𝙷','I':'𝙸','J':'𝙹',
        'K':'𝙺','L':'𝙻','M':'𝙼','N':'𝙽','O':'𝙾','P':'𝙿','Q':'𝚀','R':'𝚁','S':'𝚂','T':'𝚃',
        'U':'𝚄','V':'𝚅','W':'𝚆','X':'𝚇','Y':'𝚈','Z':'𝚉',
        'a':'𝚊','b':'𝚋','c':'𝚌','d':'𝚍','e':'𝚎','f':'𝚏','g':'𝚐','h':'𝚑','i':'𝚒','j':'𝚓',
        'k':'𝚔','l':'𝚕','m':'𝚖','n':'𝚗','o':'𝚘','p':'𝚙','q':'𝚚','r':'𝚛','s':'𝚜','t':'𝚝',
        'u':'𝚞','v':'𝚟','w':'𝚠','x':'𝚡','y':'𝚢','z':'𝚣',
        '0':'𝟶','1':'𝟷','2':'𝟸','3':'𝟹','4':'𝟺','5':'𝟻','6':'𝟼','7':'𝟽','8':'𝟾','9':'𝟿'
    };
    const UNDERLINE = '\u0332';

    // ═══════════════════════════════════════════════════════════════
    // CONVERTER FUNCTIONS
    // ═══════════════════════════════════════════════════════════════

    function toBold(str)   { let r=''; for (const c of str) r += BOLD[c]  ||c; return r; }
    function toItalic(str) { let r=''; for (const c of str) r += ITALIC[c]||c; return r; }
    function toMono(str)   { let r=''; for (const c of str) r += MONO[c]  ||c; return r; }

    // ═══════════════════════════════════════════════════════════════
    // HTML → UNICODE
    // ═══════════════════════════════════════════════════════════════

    function htmlToUnicode(root) {
        const result = [];
        let listDepth = 0, listType = [], listCounters = [];

        function process(node, fmt) {
            if (!node) return;

            if (node.nodeType === Node.TEXT_NODE) {
                let text = node.textContent;
                if (!text) return;
                if (fmt.mono) {
                    text = toMono(text);
                } else {
                    if (fmt.bold && fmt.italic) text = toBold(toItalic(text));
                    else if (fmt.bold)   text = toBold(text);
                    else if (fmt.italic) text = toItalic(text);
                    if (fmt.underline) {
                        let t = '';
                        for (const c of text) t += c + UNDERLINE;
                        text = t;
                    }
                }
                result.push(text);
                return;
            }

            if (node.nodeType !== Node.ELEMENT_NODE) return;

            const tag = node.tagName.toLowerCase();
            const nf  = { ...fmt };

            if (tag === 'strong' || tag === 'b') nf.bold = true;
            if (tag === 'em'     || tag === 'i') nf.italic = true;
            if (tag === 'code'   || tag === 'kbd' || tag === 'samp') nf.mono = true;
            if (tag === 'u'      || tag === 'ins') nf.underline = true;

            let prefix = '', suffix = '';

            switch (tag) {
                case 'h1': prefix='\n❒ '; nf.bold=true; suffix='\n'; break;
                case 'h2': prefix='\n➜ '; nf.bold=true; suffix='\n'; break;
                case 'h3': case 'h4': case 'h5': case 'h6':
                    prefix='\n▸ '; nf.bold=true; suffix='\n'; break;
                case 'li': {
                    const indent = '    '.repeat(Math.max(0, listDepth-1));
                    if (listType[listDepth-1]==='ol') {
                        listCounters[listDepth-1] = (listCounters[listDepth-1]||0)+1;
                        const n = listCounters[listDepth-1];
                        const pn = ['⑴','⑵','⑶','⑷','⑸','⑹','⑺','⑻','⑼','⑽','⑾','⑿','⒀','⒁','⒂','⒃','⒄','⒅','⒆','⒇'];
                        prefix = indent + (n<=20 ? pn[n-1] : n+'.') + ' ';
                    } else {
                        const bullets = ['◉','•','•','•','•'];
                        prefix = indent + bullets[Math.min(listDepth-1, bullets.length-1)] + ' ';
                    }
                    suffix = '\n';
                    break;
                }
                case 'ul': case 'ol':
                    if (listDepth > 0) result.push('\n');
                    listDepth++; listType.push(tag); listCounters.push(0);
                    for (const child of node.childNodes) process(child, nf);
                    listDepth--; listType.pop(); listCounters.pop();
                    if (listDepth === 0) result.push('\n');
                    return;
                case 'blockquote': prefix='│ '; suffix='\n'; break;
                case 'hr': result.push('\n────────────────────\n'); return;
                case 'br': result.push('\n'); return;
                case 'p': case 'div': if (listDepth===0) suffix='\n'; break;
                case 'pre': suffix='\n'; break;
                case 'del': case 's': case 'strike': prefix='–'; suffix='–'; break;
            }

            if (prefix) result.push(prefix);
            for (const child of node.childNodes) process(child, nf);
            if (suffix) result.push(suffix);
        }

        process(root, { bold:false, italic:false, mono:false, underline:false });

        return result.join('')
            .replace(/\n{3,}/g, '\n\n')
            .replace(/\n{2}([◉•⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇])/g, '\n$1')
            .trim();
    }

    // ═══════════════════════════════════════════════════════════════
    // CLIPBOARD
    // ═══════════════════════════════════════════════════════════════

    async function copyToClipboard(text) {
        if (navigator.clipboard && navigator.clipboard.writeText) {
            await navigator.clipboard.writeText(text);
            return;
        }
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
    }

    // ═══════════════════════════════════════════════════════════════
    // STYLES
    // ═══════════════════════════════════════════════════════════════

    function ensureStyles() {
        if (document.getElementById('mdu-styles')) return;
        const css = document.createElement('style');
        css.id = 'mdu-styles';
        css.textContent = `
            /* ── Floating persistent button (ChatGPT only) ── */
            #mdu-float {
                position: fixed !important;
                bottom: 24px !important;
                right: 24px !important;
                z-index: 2147483647 !important;
                display: flex !important;
                align-items: center;
                gap: 7px;
                background: linear-gradient(135deg, #6366f1, #8b5cf6) !important;
                border: none !important;
                border-radius: 999px !important;
                padding: 10px 20px !important;
                cursor: pointer !important;
                color: #fff !important;
                font-size: 13px !important;
                font-weight: 600 !important;
                font-family: system-ui, -apple-system, sans-serif !important;
                box-shadow: 0 4px 18px rgba(99,102,241,0.45) !important;
                transition: background 0.2s, opacity 0.2s;
                user-select: none;
                opacity: 0.35;
                pointer-events: auto !important;
            }
            #mdu-float:hover  { opacity: 1 !important; transform: scale(1.04); }
            #mdu-float:active { opacity: 0.8 !important; transform: scale(0.96); }
            #mdu-float.has-sel { opacity: 1 !important; }
            #mdu-float.done { background: linear-gradient(135deg,#10b981,#059669) !important; }
            #mdu-float svg  { width:15px; height:15px; flex-shrink:0; }

            /* ── Selection popup (non-ChatGPT sites) ── */
            #mdu-popup {
                position: fixed;
                z-index: 2147483647;
                display: none;
                font-family: system-ui, -apple-system, sans-serif;
                pointer-events: none;
            }
            #mdu-popup.show {
                display: block;
                animation: mdu-in 0.15s ease-out;
                pointer-events: auto;
            }
            @keyframes mdu-in {
                from { opacity:0; transform:translateY(6px); }
                to   { opacity:1; transform:translateY(0); }
            }
            #mdu-btn {
                display: flex;
                align-items: center;
                gap: 6px;
                background: linear-gradient(135deg, #6366f1, #8b5cf6);
                border: none;
                border-radius: 10px;
                padding: 9px 14px;
                cursor: pointer;
                color: #fff;
                font-size: 13px;
                font-weight: 600;
                box-shadow: 0 4px 14px rgba(99,102,241,0.4);
                transition: transform 0.1s;
            }
            #mdu-btn:hover  { transform: scale(1.04); }
            #mdu-btn:active { transform: scale(0.96); }
            #mdu-btn.done   { background: linear-gradient(135deg,#10b981,#059669); }
            #mdu-btn svg    { width:16px; height:16px; }

            /* ── Toast ── */
            .mdu-toast {
                position: fixed !important;
                bottom: 80px !important;
                right: 24px !important;
                background: #10b981 !important;
                color: #fff !important;
                padding: 10px 20px !important;
                border-radius: 8px !important;
                font-size: 13px !important;
                font-weight: 500 !important;
                font-family: system-ui, -apple-system, sans-serif !important;
                z-index: 2147483647 !important;
                animation: mdu-toast-in 0.2s ease-out;
                box-shadow: 0 4px 14px rgba(0,0,0,0.15) !important;
                pointer-events: none !important;
            }
            @keyframes mdu-toast-in {
                from { opacity:0; transform:translateY(10px); }
                to   { opacity:1; transform:translateY(0); }
            }
        `;
        (document.head || document.documentElement).appendChild(css);
    }

    ensureStyles();

    // ═══════════════════════════════════════════════════════════════
    // TOAST
    // ═══════════════════════════════════════════════════════════════

    function toast(msg, duration = 2000) {
        document.querySelectorAll('.mdu-toast').forEach(el => el.remove());
        const el = document.createElement('div');
        el.className = 'mdu-toast';
        el.textContent = msg;
        document.body.appendChild(el);
        setTimeout(() => el.remove(), duration);
    }

    // ═══════════════════════════════════════════════════════════════
    // SHARED STATE
    // ═══════════════════════════════════════════════════════════════

    let savedRange = null;
    let savedText  = '';

    function snapshotSelection() {
        const sel = window.getSelection();
        if (!sel || sel.rangeCount === 0) return false;
        const text = sel.toString().trim();
        if (!text) return false;
        try {
            savedRange = sel.getRangeAt(0).cloneRange();
            savedText  = text;
            return true;
        } catch (_) { return false; }
    }

    async function doCopy(btnEl, labelEl) {
        if (!savedRange) { toast('Select some text first'); return; }
        try {
            const frag    = savedRange.cloneContents();
            const wrapper = document.createElement('div');
            wrapper.appendChild(frag);
            const unicode = htmlToUnicode(wrapper);
            if (!unicode) { toast('Nothing to copy'); return; }
            await copyToClipboard(unicode);
            btnEl.classList.add('done');
            if (labelEl) labelEl.textContent = 'Copied!';
            toast('Copied as Unicode ✓');
            setTimeout(() => {
                btnEl.classList.remove('done');
                if (labelEl) labelEl.textContent = 'Copy Unicode';
            }, 1500);
        } catch (err) {
            console.error('[mdu]', err);
            toast('Copy failed: ' + err.message);
        }
    }

    // ═══════════════════════════════════════════════════════════════
    // INPUT CHECKER
    // ═══════════════════════════════════════════════════════════════

    // Returns true if the element is a standard text input or textarea
    function isTextInput(el) {
        if (!el || !el.tagName) return false;
        const tag = el.tagName.toUpperCase();
        if (tag === 'TEXTAREA') return true;
        if (tag === 'INPUT') {
            const type = (el.type || 'text').toLowerCase();
            // Types that contain text selection
            return ['text', 'search', 'email', 'password', 'url', 'tel', 'number'].includes(type);
        }
        return false;
    }

    // ═══════════════════════════════════════════════════════════════
    // CHATGPT — PERSISTENT FLOATING BUTTON
    // ═══════════════════════════════════════════════════════════════

    if (IS_CHATGPT) {

        function updateFloatState(floatBtn) {
            const sel = window.getSelection();
            const hasText = sel && sel.toString().trim().length > 0;
            if (hasText) {
                snapshotSelection();
                floatBtn.classList.add('has-sel');
                floatBtn.title = 'Copy selected text as Unicode';
            } else if (savedText) {
                floatBtn.classList.add('has-sel');
                floatBtn.title = 'Copy last selection as Unicode';
            } else {
                floatBtn.classList.remove('has-sel');
                floatBtn.title = 'Select text, then click to copy as Unicode';
            }
        }

        function injectFloatBtn() {
            if (document.getElementById('mdu-float')) return;

            const floatBtn = document.createElement('button');
            floatBtn.id   = 'mdu-float';
            floatBtn.type = 'button';
            floatBtn.title = 'Select text, then click to copy as Unicode';
            floatBtn.innerHTML = `
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <rect x="9" y="9" width="13" height="13" rx="2"/>
                    <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
                </svg>
                <span id="mdu-float-label">Copy Unicode</span>`;

            document.body.appendChild(floatBtn);
            const floatLabel = floatBtn.querySelector('#mdu-float-label');

            document.addEventListener('selectionchange', () => updateFloatState(floatBtn));

            document.addEventListener('pointerup', () => {
                Promise.resolve().then(snapshotSelection);
            }, { capture: true, passive: true });

            floatBtn.addEventListener('click', async e => {
                e.preventDefault();
                e.stopPropagation();
                await doCopy(floatBtn, floatLabel);
            });

            updateFloatState(floatBtn);
        }

        injectFloatBtn();

        const guardian = new MutationObserver(() => {
            ensureStyles();
            if (!document.getElementById('mdu-float')) {
                injectFloatBtn();
            }
        });

        guardian.observe(document.body, { childList: true, subtree: false });

        const _pushState    = history.pushState.bind(history);
        const _replaceState = history.replaceState.bind(history);

        history.pushState = function (...args) {
            _pushState(...args);
            setTimeout(injectFloatBtn, 300);
        };
        history.replaceState = function (...args) {
            _replaceState(...args);
            setTimeout(injectFloatBtn, 300);
        };

        window.addEventListener('popstate', () => setTimeout(injectFloatBtn, 300));

    } else {

        // ═══════════════════════════════════════════════════════════
        // ALL OTHER SITES — SELECTION POPUP
        // ═══════════════════════════════════════════════════════════

        const popup = document.createElement('div');
        popup.id = 'mdu-popup';
        popup.innerHTML = `
            <button id="mdu-btn" type="button">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <rect x="9" y="9" width="13" height="13" rx="2"/>
                    <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
                </svg>
                <span id="mdu-label">Copy Unicode</span>
            </button>`;
        document.body.appendChild(popup);

        const btn   = popup.querySelector('#mdu-btn');
        const label = popup.querySelector('#mdu-label');

        let popupVisible = false;

        function showPopup(x, y) {
            popup.style.left = Math.max(10, Math.min(x-70, innerWidth-160)) + 'px';
            popup.style.top  = Math.max(10, y-55) + 'px';
            btn.classList.remove('done');
            label.textContent = 'Copy Unicode';
            popup.classList.add('show');
            popupVisible = true;
        }

        function hidePopup() {
            popup.classList.remove('show');
            popupVisible = false;
        }

        document.addEventListener('selectionchange', () => {
            // If focus is inside a text input, do not process selection.
            // This prevents cursor glitches in chat inputs (e.g. chat.z.ai).
            if (isTextInput(document.activeElement)) return;

            const sel = window.getSelection();
            if (sel && sel.toString().trim()) snapshotSelection();
        });

        document.addEventListener('pointerdown', e => {
            if (!popup.contains(e.target) && popupVisible) hidePopup();
        }, true);

        document.addEventListener('pointerup', e => {
            if (popup.contains(e.target)) return;

            // FIX: Ignore clicks inside text inputs (like the chat.z.ai textarea)
            // This prevents the cursor desync issue and unnecessary popup checks.
            if (isTextInput(e.target)) return;

            const x = e.clientX, y = e.clientY;
            Promise.resolve().then(() => {
                if (snapshotSelection()) {
                    showPopup(x, y);
                }
            });
        }, true);

        document.addEventListener('keyup', e => {
            if (!e.shiftKey) return;
            // Ignore if inside input
            if (isTextInput(document.activeElement)) return;

            setTimeout(() => {
                if (!snapshotSelection()) return;
                try {
                    const rect = window.getSelection().getRangeAt(0).getBoundingClientRect();
                    showPopup(rect.left + rect.width/2, rect.top);
                } catch (_) {}
            }, 10);
        });

        document.addEventListener('keydown', e => {
            if (e.key === 'Escape') hidePopup();
        });

        btn.addEventListener('click', async e => {
            e.preventDefault();
            e.stopPropagation();
            await doCopy(btn, label);
            setTimeout(hidePopup, 900);
        });
    }

})();