Greasy Fork

Greasy Fork is available in English.

Hashtag Bundler for X/Twitter and Bluesky

Simplifies hashtag management on X/Twitter and Bluesky by letting you organize hashtags into collapsible bundles, quickly insert or copy them into the composer, and create combined bundles using a pop-up selector. Includes export/import, editing tools, and automatic detection of the composer so the panel appears only when needed.

当前为 2025-11-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Hashtag Bundler for X/Twitter and Bluesky
// @namespace    https://codymkw.nekoweb.org/
// @version      2.6.2
// @description  Simplifies hashtag management on X/Twitter and Bluesky by letting you organize hashtags into collapsible bundles, quickly insert or copy them into the composer, and create combined bundles using a pop-up selector. Includes export/import, editing tools, and automatic detection of the composer so the panel appears only when needed.
// @match        https://twitter.com/*
// @match        https://x.com/*
// @match        https://bsky.app/*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    //-------------------------------------------------------
    // Host / Storage setup
    //-------------------------------------------------------
    let PANEL_ID, STORAGE_KEY;
    const host = location.hostname;
    if (host === "twitter.com" || host === "x.com") {
        PANEL_ID = "twitter-hashtag-panel";
        STORAGE_KEY = "twitterHashtagBundles";
    } else if (host === "bsky.app") {
        PANEL_ID = "bluesky-hashtag-panel";
        STORAGE_KEY = "blueskyHashtagBundles";
    } else return;

    const COMBINED_KEY = STORAGE_KEY + "_combined";
    const COLLAPSED_KEY = STORAGE_KEY + "_collapsed";

    //-------------------------------------------------------
    // Storage helpers
    //-------------------------------------------------------
    const loadData = () => {
        try { return JSON.parse(GM_getValue(STORAGE_KEY, "{}") || "{}"); } catch { return {}; }
    };
    const saveData = (v) => GM_setValue(STORAGE_KEY, JSON.stringify(v));

    const loadCombined = () => {
        try { return JSON.parse(GM_getValue(COMBINED_KEY, "{}") || "{}"); } catch { return {}; }
    };
    const saveCombined = (v) => GM_setValue(COMBINED_KEY, JSON.stringify(v));

    const loadCollapsed = () => {
        try { return JSON.parse(GM_getValue(COLLAPSED_KEY, "false")); } catch { return false; }
    };
    const saveCollapsed = (v) => GM_setValue(COLLAPSED_KEY, JSON.stringify(Boolean(v)));

    //-------------------------------------------------------
    // DOM helper
    //-------------------------------------------------------
    const el = (tag, props = {}, kids = []) => {
        const e = document.createElement(tag);
        for (const k in props) {
            if (k === "style" && typeof props[k] === "object") Object.assign(e.style, props[k]);
            else if (k === "html") e.innerHTML = props[k];
            else e[k] = props[k];
        }
        kids.forEach(c => e.appendChild(c));
        return e;
    };

    //-------------------------------------------------------
    // Auto Combined Name — Option C1
    //-------------------------------------------------------
    const makeAutoName = (sources) => {
        const getInitials = (s) => {
            const parts = s.split(/[\W_]+/).filter(Boolean);
            if (!parts.length) return s.slice(0, 3).toUpperCase();
            return parts.map(p => p[0].toUpperCase()).slice(0, 4).join('');
        };
        const safeSourceKey = (s) => s.replace(/[^\w\-]/g, "_").replace(/_+/g, "_");
        const initials = sources.map(getInitials).join("_");
        const safeNames = sources.map(safeSourceKey);
        const ts = Date.now();
        const shortTs = String(ts).slice(-5);
        return { key: `Combined_${safeNames.join("_")}_${ts}`, label: `Combined_${initials}_${shortTs}` };
    };

    //-------------------------------------------------------
    // Insert helper: ensure insertion is appended to end for contenteditable
    // - For contenteditable: move caret to end, focus, execCommand('insertText') -> NO InputEvent dispatch
    // - If execCommand fails on contenteditable: fallback to textContent += text (no event)
    // - For textarea: append to .value and dispatch 'input' event (kept)
    //-------------------------------------------------------
    function getComposerTarget() {
        // 1) Twitter structure B: div[data-testid="tweetTextarea_0"] -> inner contenteditable div
        let node = document.querySelector('div[data-testid="tweetTextarea_0"]');
        if (node) {
            // try find inner contenteditable div
            const inner = node.querySelector('div[contenteditable="true"], [contenteditable="true"]');
            if (inner) return { type: 'contenteditable', node: inner };
            // fallback: maybe node itself is contenteditable
            if (node.getAttribute && node.getAttribute('contenteditable') === 'true') return { type: 'contenteditable', node };
        }

        // 2) generic contenteditable
        const generic = document.querySelector('div[contenteditable="true"], [contenteditable="true"]');
        if (generic) return { type: 'contenteditable', node: generic };

        // 3) textarea fallback
        const ta = document.querySelector('textarea');
        if (ta) return { type: 'textarea', node: ta };

        return null;
    }

    function moveCaretToEnd(contentEditableNode) {
        try {
            const range = document.createRange();
            range.selectNodeContents(contentEditableNode);
            range.collapse(false); // move to end
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);
        } catch (err) {
            // ignore
        }
    }

    function insertTextAtEnd(text) {
        const target = getComposerTarget();
        if (!target) return false;

        if (target.type === 'textarea') {
            // append to textarea value and dispatch input
            const ta = target.node;
            try { ta.focus(); } catch {}
            ta.value = ta.value + text;
            try { ta.dispatchEvent(new Event('input', { bubbles: true })); } catch {}
            return true;
        } else if (target.type === 'contenteditable') {
            const node = target.node;
            // move caret to end first (forces append)
            moveCaretToEnd(node);
            try { node.focus(); } catch (e) {}
            try {
                // execCommand insertText (v2.3 style)
                const success = document.execCommand('insertText', false, text);
                // do NOT dispatch InputEvent afterwards (prevents duplication on Twitter)
                if (success) return true;
                // if execCommand returned false, fallback to changing textContent (no event)
                node.textContent = node.textContent + text;
                return true;
            } catch (err) {
                // fallback: append textContent (no event)
                try {
                    node.textContent = node.textContent + text;
                    return true;
                } catch (ee) {
                    return false;
                }
            }
        }
        return false;
    }

    //-------------------------------------------------------
    // Simple confirm text helper (text-only) - resets after 3s
    //-------------------------------------------------------
    const confirmButton = (btn, originalText, confirmText) => {
        try { btn.textContent = confirmText; } catch {}
        setTimeout(() => { try { btn.textContent = originalText; } catch {} }, 3000);
    };

    //-------------------------------------------------------
    // Panel creation & render (v2.6.2)
    //-------------------------------------------------------
    const createPanel = () => {
        if (document.getElementById(PANEL_ID)) return;

        const panel = el('div', {
            id: PANEL_ID,
            style: `
                position:fixed;right:20px;bottom:20px;width:320px;
                background:#1f1f1f;color:white;border-radius:12px;padding:10px;
                font-family:sans-serif;box-shadow:0 4px 14px rgba(0,0,0,0.5);
                z-index:999999;
            `
        });

        const header = el('div', {
            style: 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;margin-bottom:8px;'
        });

        const title = el('span', { textContent: 'Hashtag Bundles', style: 'font-weight:700;' });
        const arrow = el('span', { textContent: '▼', style: 'font-size:14px;' });
        header.append(title, arrow);

        const container = el('div', { id: 'bundle-container', style: 'max-height:380px;overflow-y:auto;padding-right:6px;' });

        panel.append(header, container);
        document.body.append(panel);

        let collapsed = loadCollapsed();
        container.style.display = collapsed ? 'none' : 'block';
        arrow.textContent = collapsed ? '▲' : '▼';
        header.onclick = () => {
            collapsed = !collapsed;
            saveCollapsed(collapsed);
            container.style.display = collapsed ? 'none' : 'block';
            arrow.textContent = collapsed ? '▲' : '▼';
        };

        render();

        function render() {
            container.innerHTML = '';
            const data = loadData();

            // New Bundle
            const newBtn = el('button', {
                textContent: '➕ New Bundle',
                style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#5865F2;color:white;font-weight:700;cursor:pointer;'
            });
            newBtn.onclick = () => {
                const name = prompt('Bundle name?'); if (!name) return;
                const tags = prompt('Enter hashtags separated by spaces:'); if (!tags) return;
                data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t);
                saveData(data);
                render();
            };
            container.append(newBtn);

            // Combine
            const combineBtn = el('button', {
                textContent: '🔗 Combine Bundles',
                style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#9b59b6;color:white;font-weight:700;cursor:pointer;'
            });
            combineBtn.onclick = openCreateCombinedModal;
            container.append(combineBtn);

            // Show combined
            const showCombined = el('button', {
                textContent: '📂 Show Combined Bundles',
                style: 'width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#8e44ad;color:white;font-weight:700;cursor:pointer;'
            });
            showCombined.onclick = openCombinedPopup;
            container.append(showCombined);

            // Export / Import
            const ioWrap = el('div', { style: 'display:flex;gap:8px;margin:10px 0;' });
            const exportBtn = el('button', { textContent: 'Export', style: 'flex:1;padding:8px;border:none;border-radius:6px;background:#4CAF50;color:white;font-weight:700;cursor:pointer;' });
            const importBtn = el('button', { textContent: 'Import', style: 'flex:1;padding:8px;border:none;border-radius:6px;background:#2196F3;color:white;font-weight:700;cursor:pointer;' });

            exportBtn.onclick = () => {
                const payload = { bundles: loadData(), combined: loadCombined() };
                const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = STORAGE_KEY + '.json';
                a.click();
            };

            importBtn.onclick = () => {
                const input = document.createElement('input');
                input.type = 'file';
                input.accept = 'application/json';
                input.onchange = (e) => {
                    const f = e.target.files?.[0];
                    if (!f) return;
                    const r = new FileReader();
                    r.onload = (ev) => {
                        try {
                            const j = JSON.parse(ev.target.result);
                            saveData({ ...loadData(), ...(j.bundles || {}) });
                            saveCombined({ ...loadCombined(), ...(j.combined || {}) });
                            render();
                            alert('Import OK.');
                        } catch {
                            alert('Invalid JSON.');
                        }
                    };
                    r.readAsText(f);
                };
                input.click();
            };

            ioWrap.append(exportBtn, importBtn);
            container.append(ioWrap);

            // list bundles
            const names = Object.keys(data).sort((a, b) => a.localeCompare(b));
            if (!names.length) container.append(el('div', { textContent: 'No bundles yet.', style: 'color:#999;font-size:13px;padding:6px;' }));

            names.forEach(name => {
                const entry = el('div', { style: 'background:#262626;border-radius:8px;margin-bottom:8px;padding:8px;' });

                const row = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;' });
                const nameEl = el('span', { textContent: name, style: 'font-weight:700;font-size:14px;' });
                const arrow = el('span', { textContent: '▶', style: 'font-size:13px;transition:0.18s;' });
                row.append(nameEl, arrow);
                entry.append(row);

                const inner = el('div', { style: 'display:none;margin-top:8px;border-top:1px solid #444;padding-top:8px;font-size:12px;color:#ccc;' });
                const tagText = el('div', { textContent: data[name].join(' '), style: 'margin-bottom:8px;word-break:break-word;' });

                const controls = el('div', { style: 'display:flex;gap:8px;' });
                const mkbtn = (txt, bg, fn) => el('button', { textContent: txt, style: `flex:1;padding:6px;border:none;border-radius:6px;background:${bg};color:white;font-size:12px;font-weight:700;cursor:pointer;`, onclick: fn });

                // Insert (uses robust insertTextAtEnd)
                const insertBtn = mkbtn('Insert', '#2196F3', () => {
                    const text = data[name].join(' ') + ' ';
                    const ok = insertTextAtEnd(text);
                    if (!ok) return alert('Insert failed (composer not found).');
                    confirmButton(insertBtn, 'Insert', 'Inserted!');
                });

                // Copy
                const copyBtn = mkbtn('Copy', '#4CAF50', async () => {
                    await navigator.clipboard.writeText(data[name].join(' ')).catch(() => alert('Copy error'));
                    confirmButton(copyBtn, 'Copy', 'Copied!');
                });

                // Edit
                const editBtn = mkbtn('Edit', '#555', () => {
                    const tags = prompt('Edit hashtags:', data[name].join(' '));
                    if (!tags) return;
                    data[name] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t);
                    saveData(data);
                    render();
                });

                // Delete
                const delBtn = mkbtn('Delete', '#c0392b', () => {
                    if (!confirm(`Delete "${name}"?`)) return;
                    delete data[name];
                    saveData(data);
                    render();
                });

                controls.append(insertBtn, copyBtn, editBtn, delBtn);
                inner.append(tagText, controls);
                entry.append(inner);

                row.onclick = () => {
                    const show = inner.style.display === "none";
                    inner.style.display = show ? "block" : "none";
                    arrow.style.transform = show ? "rotate(90deg)" : "rotate(0deg)";
                };

                container.append(entry);
            });
        }
    };

    //-------------------------------------------------------
    // Create Combined Modal (checkbox based)
    //-------------------------------------------------------
    const openCreateCombinedModal = () => {
        const data = loadData();
        const bundles = Object.keys(data).sort();
        if (!bundles.length) return alert('No bundles available.');
        if (document.getElementById('combine-backdrop')) return;

        const backdrop = el('div', { id: 'combine-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' });
        document.body.append(backdrop);

        const modal = el('div', { style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:360px;padding:14px;border-radius:12px;z-index:1000000;box-shadow:0 6px 24px rgba(0,0,0,0.6);' });
        backdrop.append(modal);

        backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
        modal.onclick = (e) => e.stopPropagation();

        modal.append(el('div', { textContent: 'Combine Bundles', style: 'font-weight:700;margin-bottom:10px;font-size:15px;' }));
        modal.append(el('div', { textContent: 'Select bundles (order of checking determines tag order):', style: 'color:#ccc;margin-bottom:8px;font-size:13px;' }));

        let order = [];
        const list = el('div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:10px;max-height:220px;overflow:auto;' });
        bundles.forEach(n => {
            const row = el('div', { style: 'display:flex;align-items:center;gap:8px;background:#262626;padding:8px;border-radius:6px;cursor:pointer;' });
            const cb = el('input', { type: 'checkbox' });
            cb.onclick = (e) => e.stopPropagation();
            cb.onchange = () => {
                if (cb.checked) order.push(n); else order = order.filter(x => x !== n);
                updatePreview();
            };
            const lbl = el('span', { textContent: n, style: 'font-weight:600;cursor:pointer;' });
            lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); };
            row.append(cb, lbl);
            list.append(row);
        });
        modal.append(list);

        modal.append(el('div', { textContent: 'Preview:', style: 'color:#ccc;margin-bottom:6px;' }));
        const preview = el('div', { style: 'padding:8px;background:#262626;border-radius:6px;color:#ddd;min-height:28px;margin-bottom:12px;word-break:break-word;' });
        modal.append(preview);

        function updatePreview() {
            const tags = [...new Set(order.flatMap(n => data[n] || []))];
            preview.textContent = tags.join(' ') || '(none)';
        }

        const createBtn = el('button', { textContent: 'Create Combined Bundle', style: 'width:100%;padding:8px;background:#27ae60;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;margin-bottom:8px;' });
        createBtn.onclick = () => {
            if (!order.length) return alert('Select at least one bundle.');
            const auto = makeAutoName(order);
            const tags = [...new Set(order.flatMap(n => data[n] || []))];
            const combined = loadCombined();
            combined[auto.key] = { label: auto.label, fullKey: auto.key, sources: [...order], tags };
            saveCombined(combined);
            alert('Created combined bundle.');
            close();
        };

        const cancelBtn = el('button', { textContent: 'Cancel', style: 'width:100%;padding:8px;background:#7f8c8d;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;' });
        cancelBtn.onclick = close;

        modal.append(createBtn, cancelBtn);
        updatePreview();

        function close() { const b = document.getElementById('combine-backdrop'); if (b) b.remove(); }
    };

    //-------------------------------------------------------
    // Edit Combined (E1 checkbox edit)
    //-------------------------------------------------------
    const openEditCombinedModal = (key) => {
        const combined = loadCombined();
        const data = loadData();
        const combo = combined[key];
        if (!combo) return alert('Not found.');
        const bundles = Object.keys(data).sort();
        if (!bundles.length) return alert('No bundles exist.');
        if (document.getElementById('edit-backdrop')) return;

        const backdrop = el('div', { id: 'edit-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' });
        document.body.append(backdrop);

        const modal = el('div', { style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:360px;padding:14px;border-radius:12px;z-index:1000000;box-shadow:0 6px 24px rgba(0,0,0,0.6);' });
        backdrop.append(modal);

        backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
        modal.onclick = (e) => e.stopPropagation();

        modal.append(el('div', { textContent: 'Edit Combined Bundle', style: 'font-weight:700;margin-bottom:10px;font-size:15px;' }));
        modal.append(el('div', { textContent: `Current label: ${combo.label}`, style: 'color:#bbb;margin-bottom:10px;font-size:13px;' }));

        let order = [...combo.sources];

        const list = el('div', { style: 'display:flex;flex-direction:column;gap:6px;margin-bottom:10px;max-height:220px;overflow:auto;' });
        bundles.forEach(n => {
            const row = el('div', { style: 'display:flex;align-items:center;gap:8px;background:#262626;padding:8px;border-radius:6px;cursor:pointer;' });
            const cb = el('input', { type: 'checkbox' });
            cb.checked = order.includes(n);
            cb.onclick = (e) => e.stopPropagation();
            cb.onchange = () => {
                if (cb.checked) { if (!order.includes(n)) order.push(n); } else { order = order.filter(x => x !== n); }
                updatePreview();
            };
            const lbl = el('span', { textContent: n, style: 'font-weight:600;cursor:pointer;' });
            lbl.onclick = () => { cb.checked = !cb.checked; cb.onchange(); };
            row.append(cb, lbl);
            list.append(row);
        });

        modal.append(list);
        modal.append(el('div', { textContent: 'Preview:', style: 'color:#ccc;margin-bottom:6px;' }));
        const preview = el('div', { style: 'padding:8px;background:#262626;border-radius:6px;color:#ddd;min-height:28px;margin-bottom:12px;word-break:break-word;' });
        modal.append(preview);

        function updatePreview() {
            const tags = [...new Set(order.flatMap(n => data[n] || []))];
            preview.textContent = tags.join(' ') || '(none)';
        }
        updatePreview();

        const saveBtn = el('button', { textContent: 'Save Changes', style: 'width:100%;padding:8px;background:#27ae60;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;margin-bottom:8px;' });
        saveBtn.onclick = () => {
            if (!order.length) return alert('Select at least one bundle.');
            const oldKey = key;
            const oldSources = combo.sources.join('|');
            const newSources = order.join('|');
            let newKey = key;
            let newLabel = combo.label;
            if (oldSources !== newSources) {
                const auto = makeAutoName(order);
                newKey = auto.key;
                newLabel = auto.label;
                delete combined[oldKey];
            }
            const newTags = [...new Set(order.flatMap(n => data[n] || []))];
            combined[newKey] = { label: newLabel, fullKey: newKey, sources: [...order], tags: newTags };
            saveCombined(combined);
            alert('Updated.');
            close();
        };

        const cancelBtn = el('button', { textContent: 'Cancel', style: 'width:100%;padding:8px;background:#7f8c8d;color:white;border:none;border-radius:6px;font-weight:700;cursor:pointer;' });
        cancelBtn.onclick = close;

        modal.append(saveBtn, cancelBtn);
        function close() { const b = document.getElementById('edit-backdrop'); if (b) b.remove(); }
    };

    //-------------------------------------------------------
    // Combined Bundles Popup with circle close (ⓧ)
    //-------------------------------------------------------
    const openCombinedPopup = () => {
        const combined = loadCombined();
        const keys = Object.keys(combined).sort((a, b) => {
            const la = (combined[a].label || a).toLowerCase();
            const lb = (combined[b].label || b).toLowerCase();
            return la.localeCompare(lb);
        });
        if (!keys.length) return alert('No combined bundles exist.');
        if (document.getElementById('combined-backdrop')) return;

        const backdrop = el('div', { id: 'combined-backdrop', style: 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.4);z-index:999999;' });
        document.body.append(backdrop);

        const popup = el('div', { id: 'combined-popup', style: 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#1f1f1f;color:white;width:420px;padding:18px;border-radius:12px;z-index:1000000;max-height:560px;overflow-y:auto;box-shadow:0 6px 24px rgba(0,0,0,0.6);' });
        backdrop.append(popup);

        backdrop.onclick = (e) => { if (e.target === backdrop) close(); };
        popup.onclick = (e) => e.stopPropagation();

        popup.append(el('div', { textContent: 'Combined Bundles', style: 'font-weight:700;margin-bottom:12px;font-size:15px;' }));

        // circle close button (22px)
        const closeBtn = el('div', { html: '❌', style: 'position:absolute;right:12px;top:12px;font-size:22px;cursor:pointer;color:#fff;opacity:0.85;user-select:none;' });
        closeBtn.onclick = () => close();
        closeBtn.onmouseenter = () => closeBtn.style.opacity = '1';
        closeBtn.onmouseleave = () => closeBtn.style.opacity = '0.85';
        popup.append(closeBtn);

        keys.forEach(key => {
            const combo = combined[key];
            const wrap = el('div', { style: 'background:#262626;border-radius:8px;padding:10px;margin-bottom:10px;' });

            const name = el('div', { textContent: combo.label, style: 'font-weight:700;font-size:14px;margin-bottom:4px;' });
            const src = el('div', { textContent: 'from: ' + combo.sources.join(', '), style: 'color:#bbb;font-size:12px;margin-bottom:8px;' });
            const tags = el('div', { textContent: combo.tags.join(' '), style: 'color:#ddd;margin-bottom:8px;word-break:break-word;' });

            const btns = el('div', { style: 'display:flex;gap:8px;' });
            const mkbtn = (t, bg, fn) => el('button', { textContent: t, onclick: fn, style: `flex:1;padding:8px;border:none;border-radius:6px;background:${bg};color:white;font-weight:700;cursor:pointer;` });

            // Insert uses the robust append method
            const insertBtn = mkbtn('Insert', '#2196F3', () => {
                const s = combo.tags.join(' ') + ' ';
                const ok = insertTextAtEnd(s);
                if (!ok) return alert('Insert failed (composer not found).');
                confirmButton(insertBtn, 'Insert', 'Inserted!');
            });

            const copyBtn = mkbtn('Copy', '#4CAF50', async () => {
                await navigator.clipboard.writeText(combo.tags.join(' ')).catch(() => alert('Copy error'));
                confirmButton(copyBtn, 'Copy', 'Copied!');
            });

            const editBtn = mkbtn('Edit', '#f39c12', () => openEditCombinedModal(key));
            const delBtn = mkbtn('Delete', '#c0392b', () => {
                if (!confirm(`Delete "${combo.label}"?`)) return;
                const all = loadCombined();
                delete all[key];
                saveCombined(all);
                wrap.remove();
            });

            btns.append(insertBtn, copyBtn, editBtn, delBtn);
            wrap.append(name, src, tags, btns);
            popup.append(wrap);
        });

        function close() { const b = document.getElementById('combined-backdrop'); if (b) b.remove(); }
    };

    //-------------------------------------------------------
    // Auto-show panel when composer present
    //-------------------------------------------------------
    let panelVisible = false;
    const observer = new MutationObserver(() => {
        const c = document.querySelector('div[contenteditable="true"], textarea');
        if (c && !panelVisible) {
            createPanel();
            panelVisible = true;
        } else if (!c && panelVisible) {
            const p = document.getElementById(PANEL_ID);
            if (p) p.remove();
            panelVisible = false;
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    if (document.querySelector('div[contenteditable="true"], textarea')) {
        createPanel();
        panelVisible = true;
    }

})();