Greasy Fork

Greasy Fork is available in English.

Hashtag Bundler for X/Twitter and Bluesky

A floating hashtag organizer for X/Twitter and Bluesky. Create reusable hashtag bundles, collapse or expand the panel, copy or insert tags into the composer, combine bundles into new sets, and export or import everything as JSON. The panel remembers its collapsed state, combined bundles open in a dedicated popup, and actions like Insert or Copy show confirmation feedback. Fully supports both individual and combined bundles with a simple, fast UI.

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

您需要先安装一款用户脚本管理器扩展,例如 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.3
// @description  A floating hashtag organizer for X/Twitter and Bluesky. Create reusable hashtag bundles, collapse or expand the panel, copy or insert tags into the composer, combine bundles into new sets, and export or import everything as JSON. The panel remembers its collapsed state, combined bundles open in a dedicated popup, and actions like Insert or Copy show confirmation feedback. Fully supports both individual and combined bundles with a simple, fast UI.
// @match        https://twitter.com/*
// @match        https://x.com/*
// @match        https://bsky.app/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @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}` };
    };

    // ------------------------------
    // Composer detection + insert append helpers (kept from v2.6.2)
    // ------------------------------
    function getComposerTarget() {
        let node = document.querySelector('div[data-testid="tweetTextarea_0"]');
        if (node) {
            const inner = node.querySelector('div[contenteditable="true"], [contenteditable="true"]');
            if (inner) return { type: 'contenteditable', node: inner };
            if (node.getAttribute && node.getAttribute('contenteditable') === 'true') return { type: 'contenteditable', node };
        }
        const generic = document.querySelector('div[contenteditable="true"], [contenteditable="true"]');
        if (generic) return { type: 'contenteditable', node: generic };
        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);
            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') {
            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;
            moveCaretToEnd(node);
            try { node.focus(); } catch (e) {}
            try {
                const success = document.execCommand('insertText', false, text);
                if (success) return true;
                node.textContent = node.textContent + text;
                return true;
            } catch (err) {
                try { node.textContent = node.textContent + text; return true; } catch (ee) { return false; }
            }
        }
        return false;
    }

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

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

        const panel = el("div", {
            id: PANEL_ID,
            style: `
                position:fixed;right:20px;bottom:20px;width:340px;
                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;
            `
        });

        // header
        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);

        // container
        const container = el("div", { id: "bundle-container", style: "max-height:520px;overflow-y:auto;padding-right:6px;" });
        panel.append(header, container);
        document.body.append(panel);

        // persistent collapse
        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();

        // main render function
        function render() {
            container.innerHTML = "";

            const data = loadData();
            const combined = loadCombined();

            // 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 Bundles
            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 Bundles
            const showCB = 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;" });
            showCB.onclick = openCombinedPopup;
            container.append(showCB);

            // Export / Import and Reset All Data (A: under 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;" });
            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();
            };

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

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

            // Reset All Data button (under Export/Import)
            const resetBtn = el("button", { textContent: "⚠️ Reset All Data", style: "width:100%;padding:8px;margin-bottom:8px;border:none;border-radius:6px;background:#c0392b;color:white;font-weight:700;cursor:pointer;" });
            resetBtn.onclick = () => {
                if (!confirm("This will permanently delete ALL bundles and combined bundles. Continue?")) return;
                try {
                    GM_deleteValue(STORAGE_KEY);
                    GM_deleteValue(COMBINED_KEY);
                    GM_deleteValue(COLLAPSED_KEY);
                } catch (e) {
                    // fallback: clear by setting empty objects
                    saveData({});
                    saveCombined({});
                    saveCollapsed(false);
                }
                render();
                alert("All data cleared.");
            };
            container.append(resetBtn);

            // Dropdown: "Your Bundles" (L2)
            const selectLabel = el("div", { textContent: "Your Bundles", style: "font-weight:700;margin:6px 0 4px 0;" });
            const select = el("select", { id: "bundle-select", style: "width:100%;padding:8px;border-radius:6px;background:#262626;color:#fff;border:none;margin-bottom:8px;" });
            const defaultOpt = el("option", { textContent: "-- Select a bundle --", value: "" });
            select.append(defaultOpt);

            const bundleNames = Object.keys(data).sort((a, b) => a.localeCompare(b));
            bundleNames.forEach(n => {
                const opt = el("option", { textContent: n, value: n });
                select.append(opt);
            });

            container.append(selectLabel, select);

            // Detail pane for selected bundle (shows tags + buttons)
            const detail = el("div", { id: "bundle-detail", style: "background:#262626;border-radius:8px;padding:10px;margin-bottom:8px;display:none;" });
            container.append(detail);

            function renderDetail(bundleName) {
                detail.innerHTML = "";
                if (!bundleName) { detail.style.display = "none"; return; }
                const tagsArr = data[bundleName] || [];
                const nameEl = el("div", { textContent: bundleName, style: "font-weight:700;margin-bottom:6px;" });
                const tagsEl = el("div", { textContent: tagsArr.join(" "), style: "color:#ddd;margin-bottom:8px;word-break:break-word;" });

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

                // Insert (keeps existing behavior)
                const insertBtn = mkbtn("Insert", "#2196F3", () => {
                    const text = tagsArr.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(tagsArr.join(" ")).catch(() => alert("Copy error"));
                    confirmButton(copyBtn, "Copy", "Copied!");
                });

                // Edit
                const editBtn = mkbtn("Edit", "#555", () => {
                    const tags = prompt("Edit hashtags:", tagsArr.join(" "));
                    if (!tags) return;
                    data[bundleName] = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith("#") ? t : "#" + t);
                    saveData(data);
                    // refresh select and detail
                    const opts = select.querySelectorAll("option");
                    opts.forEach(o => { if (o.value === bundleName) o.textContent = bundleName; });
                    renderDetail(bundleName);
                });

                // Delete
                const delBtn = mkbtn("Delete", "#c0392b", () => {
                    if (!confirm(`Delete "${bundleName}"?`)) return;
                    delete data[bundleName];
                    saveData(data);
                    // remove option from select and hide detail
                    const opt = select.querySelector(`option[value="${CSS.escape(bundleName)}"]`);
                    if (opt) opt.remove();
                    select.value = "";
                    renderDetail("");
                    // also re-render the panel to update combined previews etc
                    render();
                });

                // On Buffer user requested no insert in Buffer, but for main script we keep insert — user asked to remove only in Buffer.
                // Append buttons (insert, copy, edit, delete)
                btnRow.append(insertBtn, copyBtn, editBtn, delBtn);

                detail.append(nameEl, tagsEl, btnRow);
                detail.style.display = "block";
            }

            // When selection changes
            select.onchange = () => {
                const val = select.value;
                renderDetail(val);
            };

            // Add an option to show combined quick-access? no — combined stays in popup per design

            // If no bundles, show message
            if (!bundleNames.length) {
                const msg = el("div", { textContent: "No bundles yet. Use New Bundle to create one.", style: "color:#999;margin:8px 0;" });
                container.append(msg);
            }

            // Render combined summary small (optional)
            const combinedSummary = el("div", { style: "font-size:12px;color:#bbb;margin-top:6px;" });
            const combinedCount = Object.keys(combined).length;
            combinedSummary.textContent = `${combinedCount} combined bundle${combinedCount === 1 ? "" : "s"}.`;
            container.append(combinedSummary);
        }
    };

    // ------------------------------
    // Create Combined Bundle Modal (E1)
    // ------------------------------
    const openCreateCombinedModal = () => {
        const data = loadData();
        const bundles = Object.keys(data).sort((a, b) => a.localeCompare(b));
        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 = (ev) => ev.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 Modal (E1)
    // ------------------------------
    const openEditCombinedModal = (key) => {
        const combined = loadCombined();
        const data = loadData();
        const combo = combined[key];
        if (!combo) return alert("Combined bundle 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 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 nameEl = el("div", { textContent: combo.label, style: "font-weight:700;font-size:14px;margin-bottom:4px;" });
            const srcEl = el("div", { textContent: "from: " + combo.sources.join(", "), style: "color:#bbb;font-size:12px;margin-bottom:8px;" });
            const tagsEl = el("div", { textContent: combo.tags.join(" "), style: "color:#ddd;margin-bottom:8px;word-break:break-word;" });

            const btnRow = 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;` });

            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();
            });

            btnRow.append(insertBtn, copyBtn, editBtn, delBtn);
            wrap.append(nameEl, srcEl, tagsEl, btnRow);
            popup.append(wrap);
        });

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

    // ------------------------------
    // Auto-panel creation when composer appears
    // ------------------------------
    let panelVisible = false;

    const observer = new MutationObserver(() => {
        const c = document.querySelector("textarea, div[contenteditable='true']");
        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("textarea, div[contenteditable='true']")) {
        createPanel();
        panelVisible = true;
    }

})();