Copy HTML formatting into Unicode Supported Formatting

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();