Greasy Fork is available in English.
Simplify hashtags on X/Twitter & Bluesky with a floating organizer: group them in collapsible bundles, insert or paste into posts easily, and share via export/import.
当前为
// ==UserScript==
// @name Hashtag Bundler for X/Twitter and Bluesky
// @namespace https://codymkw.nekoweb.org/
// @version 2.0
// @description Simplify hashtags on X/Twitter & Bluesky with a floating organizer: group them in collapsible bundles, insert or paste into posts easily, and share via export/import.
// @author Cody
// @match https://twitter.com/*
// @match https://x.com/*
// @match https://bsky.app/*
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
let PANEL_ID, STORAGE_KEY;
const hostname = window.location.hostname;
if (hostname === 'twitter.com' || hostname === 'x.com') {
PANEL_ID = 'twitter-hashtag-panel';
STORAGE_KEY = 'twitterHashtagBundles';
} else if (hostname === 'bsky.app') {
PANEL_ID = 'bluesky-hashtag-panel';
STORAGE_KEY = 'blueskyHashtagBundles';
} else return;
let panelVisible = false;
const loadData = () => JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
const saveData = (d) => GM_setValue(STORAGE_KEY, JSON.stringify(d));
const el = (tag, props = {}, children = []) => {
const e = document.createElement(tag);
Object.assign(e, props);
children.forEach(c => e.appendChild(c));
return e;
};
const createPanel = () => {
if (document.getElementById(PANEL_ID)) return;
const panel = el('div', {
id: PANEL_ID,
style: `
position: fixed;
right: 20px;
bottom: 20px;
width: 280px;
background: #1f1f1f;
color: white;
border-radius: 12px;
padding: 10px;
font-family: sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 99999;
`
});
// Scrollbar styling (dark, minimal)
const style = document.createElement("style");
style.textContent = `
#bundle-container::-webkit-scrollbar {
width: 6px;
}
#bundle-container::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
#bundle-container::-webkit-scrollbar-track {
background: #222;
}
`;
document.head.appendChild(style);
const header = el('div', {
style: 'font-weight:bold;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;'
});
const title = el('span', { textContent: 'Hashtag Bundles' });
const toggle = el('span', { textContent: '▼', style: 'font-size:14px;' });
header.append(title, toggle);
const container = el('div', {
id: 'bundle-container',
style: `
overflow-y: auto;
max-height: 300px;
padding-right: 3px;
`
});
panel.appendChild(header);
panel.appendChild(container);
document.body.appendChild(panel);
let collapsed = false;
header.onclick = () => {
collapsed = !collapsed;
container.style.display = collapsed ? 'none' : 'block';
toggle.textContent = collapsed ? '▲' : '▼';
};
const data = loadData();
const render = () => {
container.innerHTML = '';
const addBtn = el('button', {
textContent: '➕ New Bundle',
style: `
width:100%;padding:8px;margin-bottom:6px;
border:none;border-radius:6px;background:#5865F2;
color:white;font-weight:bold;cursor:pointer;
`
});
addBtn.onclick = () => {
const name = prompt('Bundle name?');
if (!name) return;
const tags = prompt('Enter hashtags separated by spaces — "#" optional:');
if (!tags) return;
const processed = tags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : `#${t}`);
data[name] = processed;
saveData(data);
render();
};
container.appendChild(addBtn);
// Export / Import
const ioControls = el('div', { style: 'display:flex;gap:6px;margin-bottom:10px;' });
const makeIOBtn = (label, bg) =>
el('button', {
textContent: label,
style: `
flex:1;padding:8px;
border:none;border-radius:6px;background:${bg};
color:white;font-weight:bold;cursor:pointer;
`
});
const exportBtn = makeIOBtn('Export', '#4CAF50');
const importBtn = makeIOBtn('Import', '#2196F3');
exportBtn.onclick = () => {
const blob = new Blob([JSON.stringify(loadData(), null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${STORAGE_KEY}.json`;
a.click();
URL.revokeObjectURL(url);
};
importBtn.onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
const merged = { ...loadData(), ...imported };
saveData(merged);
render();
alert('Import OK!');
} catch {
alert('Invalid JSON.');
}
};
reader.readAsText(file);
};
input.click();
};
ioControls.append(exportBtn, importBtn);
container.appendChild(ioControls);
// Bundles
Object.keys(data).forEach(bundleName => {
const entry = el('div', {
style: `
background:#262626;
border-radius:8px;
margin-bottom:8px;
padding:6px 8px;
`
});
const row = el('div', {
style: `
display:flex;justify-content:space-between;
align-items:center;cursor:pointer;
`
});
const nameEl = el('span', {
textContent: bundleName,
style: 'font-weight:bold;font-size:14px;'
});
const arrow = el('span', {
textContent: '▶',
style: 'font-size:13px;transition:0.2s;'
});
row.append(nameEl, arrow);
entry.appendChild(row);
const inner = el('div', {
style: `
display:none;margin-top:6px;padding-top:6px;
border-top:1px solid #444;font-size:12px;color:#ccc;
`
});
const tagText = el('div', {
textContent: data[bundleName].join(' '),
style: 'overflow-wrap:anywhere;margin-bottom:6px;'
});
const controls = el('div', { style: 'display:flex;gap:6px;' });
const makeBtn = (label, bg) =>
el('button', {
textContent: label,
style: `
padding:4px 6px;border:none;border-radius:4px;
background:${bg};color:white;cursor:pointer;font-size:11px;
`
});
const insertBtn = makeBtn('Insert', '#2196F3');
const copyBtn = makeBtn('Copy', '#4CAF50');
const editBtn = makeBtn('Edit', '#555');
const delBtn = makeBtn('Delete', '#c0392b');
insertBtn.onclick = () => {
const composer = document.querySelector('div[contenteditable="true"]');
if (!composer) return alert('Composer not found.');
const text = data[bundleName].join(' ') + ' ';
composer.focus();
document.execCommand('insertText', false, text);
insertBtn.textContent = 'Inserted!';
setTimeout(() => insertBtn.textContent = 'Insert', 800);
};
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(data[bundleName].join(' ')).catch(() => alert('Copy error'));
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 800);
};
editBtn.onclick = () => {
const newTags = prompt('Edit hashtags separated by spaces — "#" optional:', data[bundleName].join(' '));
if (!newTags) return;
data[bundleName] = newTags.split(/\s+/).filter(Boolean).map(t => t.startsWith('#') ? t : `#${t}`);
saveData(data);
render();
};
delBtn.onclick = () => {
if (!confirm(`Delete "${bundleName}"?`)) return;
delete data[bundleName];
saveData(data);
render();
};
controls.append(insertBtn, copyBtn, editBtn, delBtn);
inner.append(tagText, controls);
entry.appendChild(inner);
row.onclick = () => {
const show = inner.style.display === 'none';
inner.style.display = show ? 'block' : 'none';
arrow.style.transform = show ? 'rotate(90deg)' : 'rotate(0deg)';
};
container.appendChild(entry);
});
};
render();
panelVisible = true;
};
const removePanel = () => {
const p = document.getElementById(PANEL_ID);
if (p) p.remove();
panelVisible = false;
};
const observer = new MutationObserver(() => {
const composer = document.querySelector('div[contenteditable="true"]');
if (composer && !panelVisible) createPanel();
else if (!composer && panelVisible) removePanel();
});
observer.observe(document.body, { childList: true, subtree: true });
})();