Greasy Fork is available in English.
标签分组插件 - 创建分组、添加标签、一键打开
// ==UserScript==
// @name 标签分组插件
// @namespace http://tampermonkey.net/
// @version 1.3
// @description 标签分组插件 - 创建分组、添加标签、一键打开
// @author Capy
// @license MIT
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_openInTab
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// 只在顶层窗口运行,避免 iframe 内重复注入
if (window.top !== window.self) return;
// ========== 数据层 ==========
const STORAGE_KEY = 'tab_groups_data';
function loadGroups() {
try {
return JSON.parse(GM_getValue(STORAGE_KEY, '[]'));
} catch {
return [];
}
}
function saveGroups(groups) {
GM_setValue(STORAGE_KEY, JSON.stringify(groups));
}
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
// ========== 预设颜色(赤橙黄绿青蓝紫) ==========
const GROUP_COLORS = [
{ name: '赤', value: '#ea4335' },
{ name: '橙', value: '#fa7b17' },
{ name: '黄', value: '#fbbc04' },
{ name: '绿', value: '#34a853' },
{ name: '青', value: '#24c1e0' },
{ name: '蓝', value: '#4285f4' },
{ name: '紫', value: '#a142f4' },
{ name: '粉', value: '#f439a0' },
{ name: '灰', value: '#9aa0a6' },
];
// 根据已有分组数量,自动获取下一个彩虹色
function getNextRainbowColor() {
const current = loadGroups();
return GROUP_COLORS[current.length % GROUP_COLORS.length].value;
}
// ========== 样式注入 ==========
GM_addStyle(`
/* 分组颜色指示点容器 — 居中于主按钮(14px)上方 */
#tgm-indicator-dots {
position: fixed;
width: 14px;
z-index: 2147483645;
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: 4px;
}
.tgm-indicator-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
transition: transform 0.15s, opacity 0.2s;
opacity: 0.85;
cursor: pointer;
pointer-events: auto;
}
.tgm-indicator-dot:hover {
transform: scale(1.6);
opacity: 1;
}
/* 颜色修改弹出框 */
.tgm-color-popup {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
background: rgba(30, 30, 30, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 6px;
display: flex;
gap: 4px;
flex-wrap: wrap;
width: 120px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
z-index: 20;
}
.tgm-color-popup-dot {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.1s, transform 0.1s;
}
.tgm-color-popup-dot:hover {
transform: scale(1.2);
}
.tgm-color-popup-dot.tgm-current {
border-color: #fff;
}
#tgm-toggle-btn {
position: fixed;
z-index: 2147483646;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4285f4;
border: none;
color: transparent;
font-size: 0;
cursor: pointer;
display: block;
box-shadow: 0 0 6px rgba(66,133,244,0.5);
transition: transform 0.2s, box-shadow 0.2s;
user-select: none;
padding: 0;
}
#tgm-toggle-btn:hover {
transform: scale(1.4);
box-shadow: 0 0 10px rgba(66,133,244,0.7);
}
#tgm-panel {
position: fixed;
z-index: 2147483647;
width: 260px;
height: 480px;
min-width: 200px;
min-height: 200px;
max-width: 90vw;
max-height: calc(100vh - 60px);
background: rgba(22, 22, 22, 0.72);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #e0e0e0;
display: none;
flex-direction: column;
overflow: hidden;
}
#tgm-panel.tgm-visible {
display: flex;
}
/* 拖拽调整大小的手柄 */
.tgm-resize-handle {
position: absolute;
z-index: 10;
}
.tgm-resize-right {
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: ew-resize;
}
.tgm-resize-top {
top: -3px;
left: 0;
width: 100%;
height: 6px;
cursor: ns-resize;
}
.tgm-resize-corner {
top: -5px;
right: -5px;
width: 10px;
height: 10px;
cursor: nesw-resize;
}
#tgm-panel * {
box-sizing: border-box;
}
.tgm-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
gap: 6px;
}
.tgm-header-title {
font-size: 14px;
font-weight: 600;
}
.tgm-header-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tgm-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #e0e0e0;
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
transition: background 0.12s;
white-space: nowrap;
}
.tgm-btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.tgm-btn-primary {
background: rgba(66, 133, 244, 0.75);
border-color: rgba(66, 133, 244, 0.5);
color: #fff;
}
.tgm-btn-primary:hover {
background: rgba(90, 155, 244, 0.85);
}
.tgm-btn-danger {
background: transparent;
border-color: transparent;
color: #ea4335;
padding: 4px 6px;
}
.tgm-btn-danger:hover {
background: rgba(234,67,53,0.15);
}
.tgm-body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.tgm-body::-webkit-scrollbar {
width: 5px;
}
.tgm-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.tgm-empty {
text-align: center;
color: #888;
padding: 32px 16px;
line-height: 1.6;
}
/* 分组卡片 */
.tgm-group {
margin-bottom: 6px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.tgm-group-header {
display: flex;
align-items: center;
padding: 8px 10px;
cursor: pointer;
gap: 8px;
user-select: none;
}
.tgm-group-header:hover {
background: rgba(255,255,255,0.06);
}
.tgm-group-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.12s;
position: relative;
}
.tgm-group-dot:hover {
transform: scale(1.4);
}
.tgm-group-arrow {
font-size: 10px;
transition: transform 0.15s;
flex-shrink: 0;
color: #888;
}
.tgm-group-arrow.tgm-collapsed {
transform: rotate(-90deg);
}
.tgm-group-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgm-group-count {
font-size: 11px;
color: #888;
flex-shrink: 0;
}
.tgm-group-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.tgm-group-header:hover .tgm-group-actions {
opacity: 1;
}
.tgm-group-action-btn {
background: none;
border: none;
color: #aaa;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
font-size: 14px;
line-height: 1;
}
.tgm-group-action-btn:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.tgm-group-body {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.tgm-group-body.tgm-hidden {
display: none;
}
/* 标签页项 */
.tgm-tab {
display: flex;
align-items: center;
padding: 6px 10px 6px 28px;
gap: 8px;
cursor: pointer;
transition: background 0.1s;
}
.tgm-tab:hover {
background: rgba(255,255,255,0.06);
}
.tgm-tab-favicon {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 2px;
}
.tgm-tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.tgm-tab-remove {
opacity: 0;
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
}
.tgm-tab:hover .tgm-tab-remove {
opacity: 1;
}
.tgm-tab-remove:hover {
color: #ea4335;
}
/* 新建分组表单 */
.tgm-form {
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.tgm-form-row {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.tgm-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #e0e0e0;
padding: 6px 8px;
font-size: 12px;
outline: none;
}
.tgm-input:focus {
border-color: rgba(66, 133, 244, 0.7);
}
.tgm-color-picker {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tgm-color-dot {
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.1s, transform 0.1s;
}
.tgm-color-dot:hover {
transform: scale(1.15);
}
.tgm-color-dot.tgm-selected {
border-color: #fff;
}
/* 添加到分组的下拉菜单 */
.tgm-add-menu {
position: absolute;
background: rgba(30, 30, 30, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
min-width: 180px;
z-index: 10;
}
.tgm-add-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
}
.tgm-add-menu-item:hover {
background: rgba(255,255,255,0.08);
}
/* 拖拽排序 */
.tgm-group.tgm-dragging,
.tgm-tab.tgm-dragging {
opacity: 0.4;
}
.tgm-group.tgm-drag-over-top {
border-top: 2px solid #4285f4;
}
.tgm-group.tgm-drag-over-bottom {
border-bottom: 2px solid #4285f4;
}
.tgm-tab.tgm-drag-over-top {
box-shadow: inset 0 2px 0 #4285f4;
}
.tgm-tab.tgm-drag-over-bottom {
box-shadow: inset 0 -2px 0 #4285f4;
}
/* Toast 提示 */
.tgm-toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: rgba(40, 40, 40, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
z-index: 2147483647;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.tgm-toast.tgm-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`);
// ========== 面板尺寸持久化 ==========
const SIZE_KEY = 'tgm_panel_size';
function loadPanelSize() {
try {
return JSON.parse(GM_getValue(SIZE_KEY, 'null'));
} catch {
return null;
}
}
function savePanelSize(w, h) {
GM_setValue(SIZE_KEY, JSON.stringify({ width: w, height: h }));
}
// ========== 按钮位置持久化 ==========
const POS_KEY = 'tgm_btn_pos';
function loadBtnPos() {
try {
return JSON.parse(GM_getValue(POS_KEY, 'null'));
} catch {
return null;
}
}
function saveBtnPos(left, bottom) {
GM_setValue(POS_KEY, JSON.stringify({ left, bottom }));
}
function getBtnPos() {
return loadBtnPos() || { left: 16, bottom: 16 };
}
// 根据按钮位置同步更新三个元素的坐标
function updatePositions() {
const pos = getBtnPos();
const vw = window.innerWidth;
const vh = window.innerHeight;
// 主按钮
toggleBtn.style.left = pos.left + 'px';
toggleBtn.style.bottom = pos.bottom + 'px';
// 指示圆点:在按钮正上方
indicatorContainer.style.left = pos.left + 'px';
indicatorContainer.style.bottom = (pos.bottom + 18) + 'px';
// 面板:智能定位,避免超出屏幕
const pw = panel.offsetWidth || 260;
const ph = panel.offsetHeight || 480;
const margin = 4;
// 水平:默认与按钮左对齐,超右则左移
let panelLeft = pos.left;
panelLeft = Math.min(panelLeft, vw - pw - margin);
panelLeft = Math.max(margin, panelLeft);
// 垂直:默认在按钮上方,空间不够则在按钮下方
let panelBottom;
const spaceAbove = vh - (pos.bottom + 22); // 按钮上方到顶部的像素
if (spaceAbove >= ph + margin) {
// 上方空间足够
panelBottom = pos.bottom + 22;
} else {
// 上方不够,尝试限制在屏幕内
panelBottom = Math.min(pos.bottom + 22, vh - ph - margin);
panelBottom = Math.max(margin, panelBottom);
}
panel.style.left = panelLeft + 'px';
panel.style.bottom = panelBottom + 'px';
}
// ========== UI 构建 ==========
let groups = loadGroups();
let panelVisible = false;
let creatingGroup = false;
let selectedColor = getNextRainbowColor();
let collapsedGroups = new Set(JSON.parse(GM_getValue('tgm_collapsed', '[]')));
// 分组颜色指示圆点(显示在主按钮上方)
const indicatorContainer = document.createElement('div');
indicatorContainer.id = 'tgm-indicator-dots';
document.body.appendChild(indicatorContainer);
function renderIndicatorDots() {
indicatorContainer.innerHTML = '';
const currentGroups = loadGroups();
currentGroups.forEach(g => {
const dot = document.createElement('div');
dot.className = 'tgm-indicator-dot';
dot.style.background = g.color;
dot.title = `${g.name}(${g.tabs.length} 个标签页,点击打开全部)`;
dot.addEventListener('click', (e) => {
e.stopPropagation();
if (g.tabs.length > 0) {
g.tabs.forEach(tab => GM_openInTab(tab.url, { active: false }));
showToast(`已打开「${g.name}」的 ${g.tabs.length} 个标签页`);
} else {
showToast(`「${g.name}」没有标签页`);
}
});
indicatorContainer.appendChild(dot);
});
}
renderIndicatorDots();
// 浮动按钮
const toggleBtn = document.createElement('button');
toggleBtn.id = 'tgm-toggle-btn';
toggleBtn.textContent = '';
toggleBtn.title = '标签分组插件';
document.body.appendChild(toggleBtn);
// 主面板
const panel = document.createElement('div');
panel.id = 'tgm-panel';
// 应用保存的尺寸
const savedSize = loadPanelSize();
if (savedSize) {
panel.style.width = savedSize.width + 'px';
panel.style.height = savedSize.height + 'px';
}
// 添加初始 resize 手柄
['right', 'top', 'corner'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `tgm-resize-handle tgm-resize-${dir}`;
handle.dataset.dir = dir;
panel.appendChild(handle);
});
document.body.appendChild(panel);
// 初始化位置
updatePositions();
// ========== 主按钮拖拽移动 ==========
(function initBtnDrag() {
let isDragging = false;
let hasMoved = false;
let startX, startY, startLeft, startBottom;
const DRAG_THRESHOLD = 4; // 移动超过 4px 才算拖拽
toggleBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isDragging = true;
hasMoved = false;
startX = e.clientX;
startY = e.clientY;
const pos = getBtnPos();
startLeft = pos.left;
startBottom = pos.bottom;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
function onMove(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = startY - e.clientY; // 向上移 = bottom 增大
if (!hasMoved && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
hasMoved = true;
const newLeft = Math.max(0, Math.min(startLeft + dx, window.innerWidth - 14));
const newBottom = Math.max(0, Math.min(startBottom + dy, window.innerHeight - 14));
saveBtnPos(newLeft, newBottom);
updatePositions();
}
function onUp() {
isDragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (!hasMoved) {
// 没有拖动 = 点击,切换面板
if (panelVisible) closePanel();
else openPanel();
}
}
})();
// ========== 拖拽调整大小 ==========
(function initResize() {
let dragging = null;
let startX, startY, startW, startH;
panel.addEventListener('mousedown', (e) => {
const handle = e.target.closest('.tgm-resize-handle');
if (!handle) return;
e.preventDefault();
e.stopPropagation();
dragging = handle.dataset.dir;
startX = e.clientX;
startY = e.clientY;
startW = panel.offsetWidth;
startH = panel.offsetHeight;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
function onMouseMove(e) {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = startY - e.clientY; // 向上拖 = 增大高度
if (dragging === 'right' || dragging === 'corner') {
const newW = Math.max(200, Math.min(startW + dx, window.innerWidth * 0.9));
panel.style.width = newW + 'px';
}
if (dragging === 'top' || dragging === 'corner') {
const newH = Math.max(200, Math.min(startH + dy, window.innerHeight - 60));
panel.style.height = newH + 'px';
}
}
function onMouseUp() {
if (dragging) {
savePanelSize(panel.offsetWidth, panel.offsetHeight);
dragging = null;
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
})();
// Toast
const toast = document.createElement('div');
toast.className = 'tgm-toast';
document.body.appendChild(toast);
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('tgm-show');
setTimeout(() => toast.classList.remove('tgm-show'), 1800);
}
function getFavicon(url) {
try {
const u = new URL(url);
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`;
} catch {
return '';
}
}
// ========== 拖拽排序状态 ==========
let dragState = null; // { type: 'group'|'tab', idx, groupIdx?, tabIdx? }
function clearDragOverClasses(container) {
container.querySelectorAll('.tgm-drag-over-top, .tgm-drag-over-bottom').forEach(el => {
el.classList.remove('tgm-drag-over-top', 'tgm-drag-over-bottom');
});
}
function render() {
groups = loadGroups();
panel.innerHTML = '';
// 头部
const header = document.createElement('div');
header.className = 'tgm-header';
header.innerHTML = `
<span class="tgm-header-title">标签分组插件</span>
<div class="tgm-header-actions">
<button class="tgm-btn tgm-btn-primary" id="tgm-new-group">新建组</button>
<button class="tgm-btn" id="tgm-add-current">+ 当前页</button>
<button class="tgm-btn" id="tgm-export" title="导出 JSON">导出</button>
<button class="tgm-btn" id="tgm-import" title="导入 JSON">导入</button>
</div>
`;
panel.appendChild(header);
// 主体
const body = document.createElement('div');
body.className = 'tgm-body';
if (groups.length === 0 && !creatingGroup) {
body.innerHTML = '<div class="tgm-empty">还没有标签页组<br>点击「新建组」开始</div>';
} else {
groups.forEach((group, groupIdx) => {
const card = document.createElement('div');
card.className = 'tgm-group';
card.draggable = true;
card.dataset.groupIdx = groupIdx;
const isCollapsed = collapsedGroups.has(group.id);
// 组头
const gh = document.createElement('div');
gh.className = 'tgm-group-header';
gh.innerHTML = `
<span class="tgm-group-arrow ${isCollapsed ? 'tgm-collapsed' : ''}">▼</span>
<span class="tgm-group-dot" style="background:${group.color}" data-group-id="${group.id}" title="点击更改颜色"></span>
<span class="tgm-group-name">${escapeHtml(group.name)}</span>
<span class="tgm-group-count">${group.tabs.length}</span>
<div class="tgm-group-actions">
<button class="tgm-group-action-btn" data-action="open-all" data-id="${group.id}" title="打开全部">⧉</button>
<button class="tgm-group-action-btn" data-action="delete-group" data-id="${group.id}" title="删除组">✕</button>
</div>
`;
// 点击折叠/展开
gh.addEventListener('click', (e) => {
if (e.target.closest('.tgm-group-actions')) return;
if (e.target.closest('.tgm-group-dot')) return;
if (collapsedGroups.has(group.id)) {
collapsedGroups.delete(group.id);
} else {
collapsedGroups.add(group.id);
}
GM_setValue('tgm_collapsed', JSON.stringify([...collapsedGroups]));
render();
});
// --- 分组拖拽排序 ---
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', '');
e.dataTransfer.effectAllowed = 'move';
dragState = { type: 'group', idx: groupIdx };
requestAnimationFrame(() => card.classList.add('tgm-dragging'));
});
card.addEventListener('dragend', () => {
card.classList.remove('tgm-dragging');
clearDragOverClasses(body);
dragState = null;
});
card.addEventListener('dragover', (e) => {
if (!dragState || dragState.type !== 'group') return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearDragOverClasses(body);
const rect = card.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
card.classList.add('tgm-drag-over-top');
} else {
card.classList.add('tgm-drag-over-bottom');
}
});
card.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragState || dragState.type !== 'group') return;
const fromIdx = dragState.idx;
const rect = card.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
let toIdx = e.clientY < mid ? groupIdx : groupIdx + 1;
if (fromIdx < toIdx) toIdx--;
if (fromIdx !== toIdx) {
const moved = groups.splice(fromIdx, 1)[0];
groups.splice(toIdx, 0, moved);
saveGroups(groups);
render();
}
clearDragOverClasses(body);
dragState = null;
});
card.appendChild(gh);
// 组内标签页
const gb = document.createElement('div');
gb.className = `tgm-group-body ${isCollapsed ? 'tgm-hidden' : ''}`;
group.tabs.forEach((tab, tabIdx) => {
const tabEl = document.createElement('div');
tabEl.className = 'tgm-tab';
tabEl.draggable = true;
tabEl.dataset.groupIdx = groupIdx;
tabEl.dataset.tabIdx = tabIdx;
tabEl.innerHTML = `
<img class="tgm-tab-favicon" src="${getFavicon(tab.url)}" onerror="this.style.display='none'">
<span class="tgm-tab-title" title="${escapeHtml(tab.url)}">${escapeHtml(tab.title)}</span>
<button class="tgm-tab-remove" data-group="${group.id}" data-tab="${tab.id}" title="移除">✕</button>
`;
// 点击打开标签页
tabEl.addEventListener('click', (e) => {
if (e.target.closest('.tgm-tab-remove')) return;
GM_openInTab(tab.url, { active: true });
});
// --- 标签页拖拽排序 ---
tabEl.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('text/plain', '');
e.dataTransfer.effectAllowed = 'move';
dragState = { type: 'tab', groupIdx, tabIdx };
requestAnimationFrame(() => tabEl.classList.add('tgm-dragging'));
});
tabEl.addEventListener('dragend', () => {
tabEl.classList.remove('tgm-dragging');
clearDragOverClasses(body);
dragState = null;
});
tabEl.addEventListener('dragover', (e) => {
if (!dragState || dragState.type !== 'tab') return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
clearDragOverClasses(body);
const rect = tabEl.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
tabEl.classList.add('tgm-drag-over-top');
} else {
tabEl.classList.add('tgm-drag-over-bottom');
}
});
tabEl.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (!dragState || dragState.type !== 'tab') return;
const fromGIdx = dragState.groupIdx;
const fromTIdx = dragState.tabIdx;
const toGIdx = groupIdx;
const rect = tabEl.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
let toTIdx = e.clientY < mid ? tabIdx : tabIdx + 1;
// 同组内移动
if (fromGIdx === toGIdx) {
if (fromTIdx < toTIdx) toTIdx--;
if (fromTIdx !== toTIdx) {
const moved = groups[fromGIdx].tabs.splice(fromTIdx, 1)[0];
groups[fromGIdx].tabs.splice(toTIdx, 0, moved);
}
} else {
// 跨组移动
const moved = groups[fromGIdx].tabs.splice(fromTIdx, 1)[0];
groups[toGIdx].tabs.splice(toTIdx, 0, moved);
}
saveGroups(groups);
render();
clearDragOverClasses(body);
dragState = null;
});
gb.appendChild(tabEl);
});
card.appendChild(gb);
body.appendChild(card);
});
}
panel.appendChild(body);
// 新建组表单
if (creatingGroup) {
const form = document.createElement('div');
form.className = 'tgm-form';
form.innerHTML = `
<div class="tgm-form-row">
<input class="tgm-input" id="tgm-group-name" placeholder="输入组名..." autofocus>
</div>
<div class="tgm-color-picker" style="margin-bottom:8px">
${GROUP_COLORS.map(c =>
`<div class="tgm-color-dot ${c.value === selectedColor ? 'tgm-selected' : ''}"
style="background:${c.value}" data-color="${c.value}" title="${c.name}"></div>`
).join('')}
</div>
<div class="tgm-form-row">
<button class="tgm-btn" id="tgm-cancel-create">取消</button>
<button class="tgm-btn tgm-btn-primary" id="tgm-confirm-create">创建</button>
</div>
`;
panel.appendChild(form);
}
// 重新添加 resize 手柄(因为 innerHTML 清除了)
['right', 'top', 'corner'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `tgm-resize-handle tgm-resize-${dir}`;
handle.dataset.dir = dir;
panel.appendChild(handle);
});
bindEvents();
renderIndicatorDots();
}
function bindEvents() {
// 导出
const exportBtn = panel.querySelector('#tgm-export');
if (exportBtn) exportBtn.addEventListener('click', exportGroups);
// 导入
const importBtn = panel.querySelector('#tgm-import');
if (importBtn) importBtn.addEventListener('click', importGroups);
// 新建组按钮
const newGroupBtn = panel.querySelector('#tgm-new-group');
if (newGroupBtn) {
newGroupBtn.addEventListener('click', () => {
creatingGroup = true;
selectedColor = getNextRainbowColor();
render();
const input = panel.querySelector('#tgm-group-name');
if (input) input.focus();
});
}
// 取消创建
const cancelBtn = panel.querySelector('#tgm-cancel-create');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
creatingGroup = false;
render();
});
}
// 确认创建
const confirmBtn = panel.querySelector('#tgm-confirm-create');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
const input = panel.querySelector('#tgm-group-name');
const name = (input?.value || '').trim();
if (!name) {
showToast('请输入组名');
return;
}
groups.push({
id: generateId(),
name,
color: selectedColor,
tabs: [],
createdAt: Date.now(),
});
saveGroups(groups);
creatingGroup = false;
selectedColor = getNextRainbowColor();
render();
showToast('分组已创建');
});
}
// 回车创建
const nameInput = panel.querySelector('#tgm-group-name');
if (nameInput) {
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
confirmBtn?.click();
}
if (e.key === 'Escape') {
creatingGroup = false;
render();
}
});
}
// 颜色选择
panel.querySelectorAll('.tgm-color-dot').forEach(dot => {
dot.addEventListener('click', () => {
selectedColor = dot.dataset.color;
panel.querySelectorAll('.tgm-color-dot').forEach(d => d.classList.remove('tgm-selected'));
dot.classList.add('tgm-selected');
});
});
// 添加当前页
const addCurrentBtn = panel.querySelector('#tgm-add-current');
if (addCurrentBtn) {
addCurrentBtn.addEventListener('click', (e) => {
if (groups.length === 0) {
showToast('请先创建一个分组');
return;
}
showAddMenu(e.target);
});
}
// 组操作按钮
panel.querySelectorAll('.tgm-group-action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = btn.dataset.id;
if (action === 'open-all') {
const group = groups.find(g => g.id === id);
if (group && group.tabs.length > 0) {
group.tabs.forEach(tab => GM_openInTab(tab.url, { active: false }));
showToast(`已打开 ${group.tabs.length} 个标签页`);
} else {
showToast('该组没有标签页');
}
}
if (action === 'delete-group') {
const group = groups.find(g => g.id === id);
if (group && confirm(`确定删除分组「${group.name}」吗?`)) {
groups = groups.filter(g => g.id !== id);
saveGroups(groups);
render();
showToast('分组已删除');
}
}
});
});
// 点击分组颜色圆点 -> 弹出颜色选择器
panel.querySelectorAll('.tgm-group-dot[data-group-id]').forEach(dot => {
dot.addEventListener('click', (e) => {
e.stopPropagation();
// 关闭已有的颜色弹出框
panel.querySelectorAll('.tgm-color-popup').forEach(p => p.remove());
const groupId = dot.dataset.groupId;
const group = groups.find(g => g.id === groupId);
if (!group) return;
const popup = document.createElement('div');
popup.className = 'tgm-color-popup';
GROUP_COLORS.forEach(c => {
const cd = document.createElement('div');
cd.className = `tgm-color-popup-dot ${c.value === group.color ? 'tgm-current' : ''}`;
cd.style.background = c.value;
cd.title = c.name;
cd.addEventListener('click', (ev) => {
ev.stopPropagation();
group.color = c.value;
saveGroups(groups);
popup.remove();
render();
});
popup.appendChild(cd);
});
// 定位在圆点旁边
const gh = dot.closest('.tgm-group-header');
gh.style.position = 'relative';
popup.style.position = 'absolute';
popup.style.left = '30px';
popup.style.top = '50%';
popup.style.transform = 'translateY(-50%)';
gh.appendChild(popup);
// 点其他地方关闭
const closePopup = (ev) => {
if (!popup.contains(ev.target)) {
popup.remove();
document.removeEventListener('mousedown', closePopup, true);
}
};
setTimeout(() => document.addEventListener('mousedown', closePopup, true), 0);
});
});
// 移除标签页
panel.querySelectorAll('.tgm-tab-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const groupId = btn.dataset.group;
const tabId = btn.dataset.tab;
const group = groups.find(g => g.id === groupId);
if (group) {
group.tabs = group.tabs.filter(t => t.id !== tabId);
saveGroups(groups);
render();
}
});
});
}
function showAddMenu(anchor) {
// 移除已有菜单
panel.querySelectorAll('.tgm-add-menu').forEach(m => m.remove());
const menu = document.createElement('div');
menu.className = 'tgm-add-menu';
groups.forEach(group => {
const item = document.createElement('div');
item.className = 'tgm-add-menu-item';
item.innerHTML = `
<span class="tgm-group-dot" style="background:${group.color}"></span>
<span>${escapeHtml(group.name)}</span>
`;
item.addEventListener('click', () => {
// 检查是否已存在
const exists = group.tabs.some(t => t.url === location.href);
if (exists) {
showToast('当前页已在该组中');
menu.remove();
return;
}
group.tabs.push({
id: generateId(),
title: document.title || location.href,
url: location.href,
addedAt: Date.now(),
});
saveGroups(groups);
menu.remove();
render();
showToast(`已添加到「${group.name}」`);
});
menu.appendChild(item);
});
// 定位:在 header 区域内使用相对定位
const header = panel.querySelector('.tgm-header');
header.style.position = 'relative';
menu.style.top = '100%';
menu.style.right = '0';
menu.style.marginTop = '4px';
header.appendChild(menu);
// 点击其他地方关闭
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeMenu, true);
}
};
setTimeout(() => document.addEventListener('click', closeMenu, true), 0);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ========== 导入导出 ==========
function exportGroups() {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
groups: loadGroups(),
panelSize: loadPanelSize(),
};
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tab-groups-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('已导出 JSON 文件');
}
function importGroups() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const raw = JSON.parse(reader.result);
// 支持两种格式:带 version 包装的和纯数组
const imported = Array.isArray(raw) ? raw : (raw.groups || []);
if (!Array.isArray(imported)) throw new Error('格式错误');
// 校验每个 group 的基本结构
for (const g of imported) {
if (!g.name || !Array.isArray(g.tabs)) throw new Error('分组数据不完整');
if (!g.id) g.id = generateId();
if (!g.color) g.color = GROUP_COLORS[0].value;
for (const t of g.tabs) {
if (!t.url) throw new Error('标签页缺少 URL');
if (!t.id) t.id = generateId();
if (!t.title) t.title = t.url;
}
}
// 合并还是替换?让用户选择
const existing = loadGroups();
if (existing.length > 0) {
const merge = confirm('检测到已有分组数据。\n\n确定 = 合并(保留已有 + 追加导入)\n取消 = 替换(清空已有,只保留导入)');
if (merge) {
const merged = [...existing, ...imported];
saveGroups(merged);
} else {
saveGroups(imported);
}
} else {
saveGroups(imported);
}
// 恢复面板尺寸
if (raw.panelSize && raw.panelSize.width && raw.panelSize.height) {
savePanelSize(raw.panelSize.width, raw.panelSize.height);
panel.style.width = raw.panelSize.width + 'px';
panel.style.height = raw.panelSize.height + 'px';
}
render();
showToast(`已导入 ${imported.length} 个分组`);
} catch (err) {
showToast('导入失败: ' + (err.message || '文件格式错误'));
}
};
reader.readAsText(file);
input.remove();
});
document.body.appendChild(input);
input.click();
}
// ========== 事件绑定 ==========
function openPanel() {
panelVisible = true;
panel.classList.add('tgm-visible');
render();
}
function closePanel() {
panelVisible = false;
panel.classList.remove('tgm-visible');
}
// 点击面板外部关闭
document.addEventListener('mousedown', (e) => {
if (!panelVisible) return;
if (panel.contains(e.target) || toggleBtn.contains(e.target) || indicatorContainer.contains(e.target)) return;
closePanel();
});
// Escape 关闭面板
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panelVisible) {
closePanel();
}
});
// 快捷键 Alt+G 切换面板
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'g') {
e.preventDefault();
if (panelVisible) closePanel();
else openPanel();
}
});
// Tampermonkey 菜单入口
GM_registerMenuCommand('打开标签分组插件', () => {
if (!panelVisible) openPanel();
});
GM_registerMenuCommand('快速添加当前页到分组', () => {
if (!panelVisible) openPanel();
setTimeout(() => {
const btn = panel.querySelector('#tgm-add-current');
if (btn) btn.click();
}, 100);
});
})();