The tag category panel for e621.net/uploads/new + DATABASE export/import
// ==UserScript==
// @name e621 Tag Helper (Uploads)
// @namespace https://e621.net/
// @version 1.9
// @license MIT
// @description Панель категорий тегов для e621.net/uploads/new + экспорт/импорт БД
// @description:en The tag category panel for e621.net/uploads/new + DATABASE export/import
// @author DiorionArh
// @name:en Tag Cataloger
// @name:ru Каталогизатор Тегов
// @match https://e621.net/uploads/new*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ---------- IndexedDB ----------
const DB_NAME = 'e621_taghelper';
const DB_VERSION = 1;
const STORE_CATS = 'categories';
const STORE_TAGS = 'tags';
let db = null;
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = e.target.result;
if (!d.objectStoreNames.contains(STORE_CATS)) {
const s = d.createObjectStore(STORE_CATS, { keyPath: 'id', autoIncrement: true });
s.createIndex('order', 'order');
}
if (!d.objectStoreNames.contains(STORE_TAGS)) {
const s = d.createObjectStore(STORE_TAGS, { keyPath: 'id', autoIncrement: true });
s.createIndex('catId', 'catId');
s.createIndex('name', 'name', { unique: false });
s.createIndex('order', 'order');
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function tx(store, mode) { return db.transaction(store, mode).objectStore(store); }
function dbGetAll(store) {
return new Promise((res, rej) => {
const r = tx(store, 'readonly').getAll();
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function dbAdd(store, obj) {
return new Promise((res, rej) => {
const r = tx(store, 'readwrite').add(obj);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function dbPut(store, obj) {
return new Promise((res, rej) => {
const r = tx(store, 'readwrite').put(obj);
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function dbDelete(store, id) {
return new Promise((res, rej) => {
const r = tx(store, 'readwrite').delete(id);
r.onsuccess = () => res();
r.onerror = () => rej(r.error);
});
}
function dbClear(store) {
return new Promise((res, rej) => {
const r = tx(store, 'readwrite').clear();
r.onsuccess = () => res();
r.onerror = () => rej(r.error);
});
}
// ---------- state ----------
let categories = [];
let tags = [];
let searchQuery = '';
let selectedTagIds = new Set();
function sortCategories() { categories.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); }
async function reloadData() {
categories = await dbGetAll(STORE_CATS);
sortCategories();
tags = await dbGetAll(STORE_TAGS);
const alive = new Set(tags.map(t => t.id));
for (const id of [...selectedTagIds]) if (!alive.has(id)) selectedTagIds.delete(id);
renderPanel();
}
// ---------- helpers ----------
function normalizeTagName(raw) { return raw.trim().replace(/\s+/g, '_').toLowerCase(); }
function findTagByNameAnywhere(name) { return tags.find(t => t.name.toLowerCase() === name.toLowerCase()); }
function findTagByNameInCat(name, catId) {
return tags.find(t => t.catId === catId && t.name.toLowerCase() === name.toLowerCase());
}
function catNameById(id) { const c = categories.find(c => c.id === id); return c ? c.name : `#${id}`; }
function catById(id) { return categories.find(c => c.id === id); }
function normalizeHex(hex) {
if (!hex) return '';
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
if (h.length !== 6) return '';
if (parseInt(h, 16) === 0xffffff) return '';
return '#' + h.toLowerCase();
}
function hexToRgba(hex, alpha) {
if (!hex) return `rgba(255,255,255,${alpha})`;
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const n = parseInt(h, 16);
if (isNaN(n) || h.length !== 6) return `rgba(255,255,255,${alpha})`;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return `rgba(${r},${g},${b},${alpha})`;
}
function effectiveColor(cat, tag) {
if (tag && tag.color) return tag.color;
if (cat && cat.color) return cat.color;
return '';
}
// ---------- export / import ----------
async function exportDB() {
const cats = await dbGetAll(STORE_CATS);
const allTags = await dbGetAll(STORE_TAGS);
const catMap = new Map(cats.map(c => [c.id, c]));
const payload = {
format: 'e621-taghelper-backup',
version: 2,
exportedAt: new Date().toISOString(),
categories: cats.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(c => ({ name: c.name, order: c.order ?? 0, color: c.color || '' })),
tags: allTags.map(t => ({
catName: catMap.get(t.catId)?.name ?? null,
name: t.name,
order: t.order ?? 0,
color: t.color || '',
})).filter(t => t.catName),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `e621_taghelper_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
showToast(`Экспорт: ${payload.categories.length} кат., ${payload.tags.length} тегов.`, 'ok');
}
async function importDB(file, mode) {
let data;
try { data = JSON.parse(await file.text()); }
catch (e) { showToast('Не удалось прочитать JSON.', 'error'); return; }
if (!data || data.format !== 'e621-taghelper-backup'
|| !Array.isArray(data.categories) || !Array.isArray(data.tags)) {
showToast('Неверный формат файла.', 'error');
return;
}
if (mode === 'replace') { await dbClear(STORE_CATS); await dbClear(STORE_TAGS); }
const existingCats = await dbGetAll(STORE_CATS);
const catByName = new Map(existingCats.map(c => [c.name.toLowerCase(), c]));
let nextOrder = existingCats.length ? Math.max(...existingCats.map(c => c.order ?? 0)) + 1 : 1;
const importedCatMap = new Map();
for (const c of data.categories) {
const name = String(c.name || '').trim();
if (!name) continue;
const key = name.toLowerCase();
let cat = catByName.get(key);
if (!cat) {
const obj = {
name,
order: Number.isFinite(c.order) ? c.order : nextOrder,
color: normalizeHex(c.color || ''),
};
const id = await dbAdd(STORE_CATS, obj);
cat = { id, ...obj };
catByName.set(key, cat);
nextOrder = Math.max(nextOrder, (obj.order ?? 0) + 1);
} else if (mode === 'replace') {
cat.color = normalizeHex(c.color || '');
await dbPut(STORE_CATS, cat);
}
importedCatMap.set(key, cat);
}
const existingTags = await dbGetAll(STORE_TAGS);
const tagKey = (catId, name) => `${catId}::${name.toLowerCase()}`;
const existingSet = new Set(existingTags.map(t => tagKey(t.catId, t.name)));
const orderByCat = new Map();
for (const t of existingTags) orderByCat.set(t.catId, Math.max(orderByCat.get(t.catId) ?? 0, t.order ?? 0));
let added = 0, skipped = 0;
for (const t of data.tags) {
const cname = String(t.catName || '').toLowerCase();
const name = normalizeTagName(String(t.name || ''));
if (!name || !cname) { skipped++; continue; }
const cat = importedCatMap.get(cname);
if (!cat) { skipped++; continue; }
const key = tagKey(cat.id, name);
if (existingSet.has(key)) { skipped++; continue; }
const order = (orderByCat.get(cat.id) ?? 0) + 1;
orderByCat.set(cat.id, order);
await dbAdd(STORE_TAGS, { catId: cat.id, name, order, color: normalizeHex(t.color || '') });
existingSet.add(key);
added++;
}
await reloadData();
showToast(`Импорт: +${added} тегов, пропущено ${skipped}.`, 'ok');
}
// ---------- UI ----------
let panelEl = null;
let stickyEl = null;
let selectionBoxEl = null;
let bulkBarEl = null;
function buildPanel() {
const wrap = document.createElement('div');
wrap.id = 'taghelper-panel';
wrap.innerHTML = `
<style>
#taghelper-panel {
position: fixed;
left: 0; top: 0;
width: 280px; height: 100vh;
overflow-y: auto;
background: #1e2124;
color: #ddd;
font: 12px/1.4 sans-serif;
z-index: 99999;
box-shadow: 2px 0 8px rgba(0,0,0,.5);
padding: 0 8px 8px 8px; box-sizing: border-box;
user-select: none;
}
body { margin-left: 280px !important; }
#taghelper-panel .sticky {
position: sticky;
top: 0;
background: #1e2124;
padding-top: 8px;
margin: 0 -8px;
padding-left: 8px;
padding-right: 8px;
z-index: 5;
box-shadow: 0 6px 8px -6px rgba(0,0,0,.6);
}
#taghelper-panel .cat { margin-bottom: 10px; margin-top: 8px; }
#taghelper-panel .cat-name {
font-weight: bold; color: #f0ad4e;
display: flex; justify-content: space-between; align-items: center;
user-select: none;
}
#taghelper-panel .cat-name .title { flex: 1; }
#taghelper-panel .cat-name button { font-size: 10px; padding: 0 4px; cursor: pointer; }
#taghelper-panel .cat-name .grip,
#taghelper-panel .tag-row .grip {
cursor: grab; color: #666; padding: 0 3px; user-select: none;
}
#taghelper-panel .cat-name .swatch {
width: 12px; height: 12px; border-radius: 3px;
border: 1px solid #555; margin-right: 4px;
cursor: pointer; display: inline-block;
box-sizing: border-box;
}
#taghelper-panel .tag-row {
display: flex; align-items: center; gap: 4px;
padding: 1px 3px;
border-top: 2px solid transparent;
border-bottom: 2px solid transparent;
border-radius: 3px;
transition: background .1s;
}
#taghelper-panel .tag-row.drag-over-top { border-top-color: #4a90e2; }
#taghelper-panel .tag-row.drag-over-bottom { border-bottom-color: #4a90e2; }
#taghelper-panel .tag-row.selected {
outline: 1px solid #4a90e2;
background: rgba(74,144,226,.25) !important;
}
#taghelper-panel .tag-row input[type=checkbox] { margin: 0; }
#taghelper-panel .tag-row label { flex: 1; word-break: break-all; cursor: pointer; }
#taghelper-panel .tag-row .del { color: #c66; cursor: pointer; padding: 0 2px; opacity: .6; }
#taghelper-panel .tag-row .del:hover { opacity: 1; }
#taghelper-panel .cat.drag-over { outline: 2px dashed #4a90e2; }
#taghelper-panel .add-row { display: flex; gap: 4px; margin-bottom: 8px; }
#taghelper-panel .add-row input {
flex: 1; padding: 2px 4px; background: #2a2d31;
border: 1px solid #444; color: #ddd; min-width: 0;
}
#taghelper-panel .add-row select { max-width: 90px; }
#taghelper-panel .add-row button { padding: 2px 6px; cursor: pointer; }
#taghelper-panel .tools {
display: flex;
gap: 4px;
margin: 4px 0 8px;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
box-sizing: border-box;
}
#taghelper-panel .tools button {
flex: 1 1 0;
min-width: 0;
padding: 3px 4px;
cursor: pointer;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#th-collapse {
width: 22px;
min-width: 22px;
max-width: 22px;
flex: 0 0 22px !important;
padding: 0 !important;
font-size: 12px;
line-height: 1;
opacity: .75;
}
#th-collapse:hover { opacity: 1; }
/* Подсказка — inline */
#taghelper-panel .hint {
display: inline;
font-size: 10px;
color: #aaa;
line-height: 1.35;
}
#taghelper-panel .hint-wrap {
padding: 4px 6px;
margin: 6px 0 8px;
background: #23272b;
border: 1px solid #2f343a;
border-radius: 3px;
width: 100%;
box-sizing: border-box;
}
#taghelper-panel .tag-row.dragging,
#taghelper-panel .cat.dragging { opacity: .4; }
#taghelper-panel .search-row { margin-bottom: 8px; }
#taghelper-panel .search-row input {
width: 100%; padding: 3px 6px; background: #2a2d31;
border: 1px solid #444; color: #ddd; box-sizing: border-box;
}
#taghelper-panel .tag-row.match {
background: rgba(255, 235, 59, 0.18);
box-shadow: inset 0 0 0 1px rgba(255, 235, 59, 0.5);
}
#taghelper-panel .tag-row.match label { font-weight: bold; color: #fff !important; }
#taghelper-panel .tag-row.dim { opacity: .35; }
#th-expand {
position: fixed; left: 2px; top: 2px;
width: 14px; height: 14px;
font-size: 11px; line-height: 12px;
padding: 0;
border: 1px solid #444;
background: #2a2d31;
color: #888;
border-radius: 3px;
cursor: pointer;
opacity: .55;
z-index: 100000;
}
#th-expand:hover { opacity: 1; color: #fff; }
#th-rubber {
position: fixed;
border: 1px solid #4a90e2;
background: rgba(74,144,226,.15);
pointer-events: none;
z-index: 100002;
display: none;
}
#th-bulk {
position: fixed;
left: 8px; bottom: 8px;
width: 264px;
background: #23272b;
border: 1px solid #3a3f44;
border-radius: 4px;
padding: 6px;
box-sizing: border-box;
display: none;
z-index: 100001;
box-shadow: 0 2px 10px rgba(0,0,0,.5);
}
#th-bulk.show { display: block; }
#th-bulk .row { display: flex; gap: 4px; align-items: center; }
#th-bulk .count { flex: 1; color: #8fbc8f; }
#th-bulk .swatch-b {
width: 18px; height: 18px; border-radius: 3px;
border: 1px solid #555; cursor: pointer;
box-sizing: border-box;
}
#th-bulk button {
padding: 2px 6px; cursor: pointer; font-size: 11px;
}
#taghelper-toast {
position: fixed; bottom: 20px; left: 300px;
background: #2b2f33; color: #fff;
padding: 8px 12px; border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,.6);
font: 12px/1.4 sans-serif;
z-index: 100003; max-width: 340px;
border-left: 3px solid #4a90e2;
opacity: 0; transform: translateY(10px);
transition: opacity .2s, transform .2s;
pointer-events: none;
}
#taghelper-toast.show { opacity: 1; transform: translateY(0); }
#taghelper-toast.warn { border-left-color: #e2a44a; }
#taghelper-toast.error { border-left-color: #e24a4a; }
#taghelper-toast.ok { border-left-color: #4ae28f; }
</style>
<div class="sticky" id="th-sticky">
<div class="tools">
<button id="th-add-cat">+ категория</button>
<button id="th-export">⇩ экспорт</button>
<button id="th-import">⇧ импорт</button>
<button id="th-collapse" title="свернуть">‹</button>
</div>
<div class="search-row">
<input id="th-search" placeholder="поиск / подсветка тегов..." />
</div>
<div class="add-row">
<input id="th-tag-input" placeholder="новый тег" />
<select id="th-tag-cat"></select>
<button id="th-add-tag">+</button>
</div>
</div>
<div class="hint-wrap"><span class="hint">Галочка — добавить/удалить. ⠿ — перетащить. 🎨 — цвет. Тяни ЛКМ — выделить. Выделенный тег + новый — вставится под ним.</span></div>
<div id="th-cats"></div>
<input type="file" id="th-import-file" accept="application/json,.json" style="display:none" />
`;
document.body.appendChild(wrap);
panelEl = wrap;
stickyEl = wrap.querySelector('#th-sticky');
selectionBoxEl = document.createElement('div');
selectionBoxEl.id = 'th-rubber';
document.body.appendChild(selectionBoxEl);
bulkBarEl = document.createElement('div');
bulkBarEl.id = 'th-bulk';
bulkBarEl.innerHTML = `
<div class="row">
<span class="count" id="th-bulk-count">0</span>
<span class="swatch-b" id="th-bulk-swatch" title="цвет выделенных"></span>
<button id="th-bulk-apply">окрасить</button>
<button id="th-bulk-reset" title="снять цвет">✖</button>
<button id="th-bulk-clear" title="снять выделение">⌫</button>
</div>
`;
document.body.appendChild(bulkBarEl);
const toast = document.createElement('div');
toast.id = 'taghelper-toast';
document.body.appendChild(toast);
wrap.querySelector('#th-collapse').onclick = (e) => {
e.stopPropagation();
wrap.style.display = 'none';
document.body.style.marginLeft = '0';
if (!document.getElementById('th-expand')) {
const btn = document.createElement('button');
btn.id = 'th-expand';
btn.textContent = '›';
btn.title = 'развернуть';
btn.onclick = () => {
wrap.style.display = '';
document.body.style.marginLeft = '280px';
btn.remove();
};
document.body.appendChild(btn);
}
};
wrap.querySelector('#th-add-cat').onclick = async () => {
const name = prompt('Название категории:');
if (!name) return;
const trimmed = name.trim();
if (!trimmed) return;
if (categories.some(c => c.name.toLowerCase() === trimmed.toLowerCase())) {
showToast(`Категория «${trimmed}» уже существует.`, 'warn');
return;
}
const order = categories.length ? Math.max(...categories.map(c => c.order || 0)) + 1 : 1;
await dbAdd(STORE_CATS, { name: trimmed, order, color: '' });
await reloadData();
};
wrap.querySelector('#th-export').onclick = async () => {
try { await exportDB(); }
catch (e) { console.error(e); showToast('Ошибка экспорта: ' + e.message, 'error'); }
};
const fileInput = wrap.querySelector('#th-import-file');
wrap.querySelector('#th-import').onclick = () => { fileInput.value = ''; fileInput.click(); };
fileInput.addEventListener('change', async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
const replace = confirm(
'Импорт файла.\n\n' +
'OK = заменить всю текущую базу данными из файла\n' +
'Отмена = слить (добавить недостающие категории/теги)'
);
await importDB(f, replace ? 'replace' : 'merge');
});
const input = wrap.querySelector('#th-tag-input');
const catSel = wrap.querySelector('#th-tag-cat');
const addTagFromInput = async () => {
const name = normalizeTagName(input.value);
if (!name) return;
// --- Определяем, есть ли выделение и «якорь» (последний по order выделенный тег) ---
const selectedTags = [...selectedTagIds]
.map(id => tags.find(t => t.id === id))
.filter(Boolean)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
const hasSelection = selectedTags.length > 0;
const anchor = hasSelection ? selectedTags[selectedTags.length - 1] : null;
// Категория: если есть выделение — категория якоря (игнорируем select), иначе из select
let catId;
if (anchor) {
catId = anchor.catId;
} else {
catId = Number(catSel.value);
if (!catId) { showToast('Сначала создайте категорию.', 'warn'); return; }
}
// --- Проверки дубликатов ---
if (findTagByNameInCat(name, catId)) {
showToast(`Тег «${name}» уже есть в категории «${catNameById(catId)}».`, 'warn');
input.focus(); input.select();
return;
}
const elsewhere = findTagByNameAnywhere(name);
if (elsewhere && elsewhere.catId !== catId) {
const ok = confirm(
`Тег «${name}» уже существует в категории «${catNameById(elsewhere.catId)}».\n` +
`Добавить его ещё и в «${catNameById(catId)}»?`
);
if (!ok) { input.focus(); input.select(); return; }
}
// --- Позиция вставки и цвет ---
let insertAfterOrder = null;
let inheritedColor = '';
if (anchor) {
// Вставка под якорем в его категории
insertAfterOrder = anchor.order ?? 0;
// Наследование цвета: tag.color || категория.color
const anchorCat = catById(anchor.catId);
inheritedColor = effectiveColor(anchorCat, anchor) || '';
}
if (insertAfterOrder != null) {
const toShift = tags.filter(t => t.catId === catId && (t.order ?? 0) > insertAfterOrder);
toShift.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
for (let i = toShift.length - 1; i >= 0; i--) {
const t = toShift[i];
t.order = (t.order ?? 0) + 1;
await dbPut(STORE_TAGS, t);
}
}
const siblings = tags.filter(t => t.catId === catId);
const order = (insertAfterOrder != null)
? insertAfterOrder + 1
: (siblings.length ? Math.max(...siblings.map(t => t.order || 0)) + 1 : 1);
await dbAdd(STORE_TAGS, {
catId,
name,
order,
color: inheritedColor,
});
input.value = '';
if (anchor) {
if (inheritedColor) {
showToast(`«${name}» добавлен под «${anchor.name}» и окрашен как он.`, 'ok');
} else {
showToast(`«${name}» добавлен под «${anchor.name}».`, 'ok');
}
}
await reloadData();
};
wrap.querySelector('#th-add-tag').onclick = addTagFromInput;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(); }
});
const searchInput = wrap.querySelector('#th-search');
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value.trim().toLowerCase();
applySearchHighlight();
});
const bulkSwatch = bulkBarEl.querySelector('#th-bulk-swatch');
const bulkColorInput = document.createElement('input');
bulkColorInput.type = 'color';
bulkColorInput.value = '#ffffff';
bulkColorInput.style.cssText = 'position:absolute;opacity:0;pointer-events:none;width:0;height:0';
bulkSwatch.appendChild(bulkColorInput);
bulkSwatch.addEventListener('click', () => bulkColorInput.click());
bulkColorInput.addEventListener('input', () => {
bulkSwatch.style.background = bulkColorInput.value;
});
bulkBarEl.querySelector('#th-bulk-apply').onclick = async () => {
const color = normalizeHex(bulkColorInput.value);
await setColorForSelected(color);
};
bulkBarEl.querySelector('#th-bulk-reset').onclick = () => setColorForSelected('');
bulkBarEl.querySelector('#th-bulk-clear').onclick = () => {
selectedTagIds.clear();
updateSelectionUI();
};
installRubberBand();
}
async function setColorForSelected(color) {
if (!selectedTagIds.size) return;
for (const id of selectedTagIds) {
const t = tags.find(x => x.id === id);
if (!t) continue;
t.color = color;
await dbPut(STORE_TAGS, t);
}
await reloadData();
showToast(color ? `Окрашено: ${selectedTagIds.size} тегов.` : `Снят цвет: ${selectedTagIds.size} тегов.`, 'ok');
}
let toastTimer = null;
function showToast(msg, kind = 'info') {
const el = document.getElementById('taghelper-toast');
if (!el) return;
el.textContent = msg;
el.className = 'show ' + kind;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = ''; }, 3500);
}
function applySearchHighlight() {
if (!panelEl) return;
const rows = panelEl.querySelectorAll('.tag-row');
if (!searchQuery) {
rows.forEach(r => r.classList.remove('match', 'dim'));
return;
}
let firstMatch = null;
rows.forEach(r => {
const lbl = r.querySelector('label');
const name = lbl ? lbl.textContent.toLowerCase() : '';
const hit = name.includes(searchQuery);
r.classList.toggle('match', hit);
r.classList.toggle('dim', !hit);
if (hit && !firstMatch) firstMatch = r;
});
if (firstMatch) firstMatch.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
function updateSelectionUI() {
if (!panelEl) return;
panelEl.querySelectorAll('.tag-row').forEach(r => {
const id = Number(r.dataset.tagId);
r.classList.toggle('selected', selectedTagIds.has(id));
});
const bar = document.getElementById('th-bulk');
if (!bar) return;
if (selectedTagIds.size > 0) {
bar.classList.add('show');
bar.querySelector('#th-bulk-count').textContent = `выделено: ${selectedTagIds.size}`;
} else {
bar.classList.remove('show');
}
}
function installRubberBand() {
let dragging = false;
let startX = 0, startY = 0;
let additive = false;
let baseSelection = new Set();
panelEl.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('input, button, select, .grip, .del, label, a')) return;
if (e.target.closest('.cat-name')) return;
if (e.target.closest('#th-sticky')) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
additive = e.shiftKey || e.ctrlKey || e.metaKey;
baseSelection = additive ? new Set(selectedTagIds) : new Set();
if (!additive) selectedTagIds.clear();
const box = selectionBoxEl;
box.style.display = 'block';
box.style.left = startX + 'px';
box.style.top = startY + 'px';
box.style.width = '0px';
box.style.height = '0px';
const onMove = (ev) => {
if (!dragging) return;
const x1 = Math.min(startX, ev.clientX);
const y1 = Math.min(startY, ev.clientY);
const x2 = Math.max(startX, ev.clientX);
const y2 = Math.max(startY, ev.clientY);
box.style.left = x1 + 'px';
box.style.top = y1 + 'px';
box.style.width = (x2 - x1) + 'px';
box.style.height = (y2 - y1) + 'px';
const rect = { left: x1, top: y1, right: x2, bottom: y2 };
const rows = panelEl.querySelectorAll('.tag-row');
selectedTagIds = new Set(baseSelection);
rows.forEach(r => {
const b = r.getBoundingClientRect();
const inter = !(b.right < rect.left || b.left > rect.right
|| b.bottom < rect.top || b.top > rect.bottom);
if (inter) selectedTagIds.add(Number(r.dataset.tagId));
});
updateSelectionUI();
};
const onUp = () => {
dragging = false;
box.style.display = 'none';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
updateSelectionUI();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
e.preventDefault();
});
panelEl.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
selectedTagIds = new Set(tags.map(t => t.id));
updateSelectionUI();
}
});
panelEl.tabIndex = 0;
}
let draggedTagIds = [];
let draggedCatId = null;
function clearTagIndicators() {
panelEl.querySelectorAll('.tag-row').forEach(r => {
r.classList.remove('drag-over-top', 'drag-over-bottom', 'dragging');
});
}
function clearCatIndicators() {
panelEl.querySelectorAll('.cat').forEach(c => c.classList.remove('drag-over', 'dragging'));
}
async function reorderTagGroup(targetTag, insertBefore) {
if (!draggedTagIds.length || !targetTag) return;
const newCatId = targetTag.catId;
const targetId = targetTag.id;
const draggedSet = new Set(draggedTagIds);
const siblings = tags
.filter(t => t.catId === newCatId && !draggedSet.has(t.id))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
let insertIdx = siblings.findIndex(t => t.id === targetId);
if (insertIdx === -1) insertIdx = siblings.length;
if (!insertBefore) insertIdx += 1;
const moving = tags
.filter(t => draggedSet.has(t.id))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
const existingNamesInTarget = new Set(siblings.map(t => t.name.toLowerCase()));
const moved = [];
const skipped = [];
for (const t of moving) {
if (existingNamesInTarget.has(t.name.toLowerCase())) { skipped.push(t.name); continue; }
existingNamesInTarget.add(t.name.toLowerCase());
moved.push(t);
}
if (!moved.length) {
if (skipped.length) showToast(`Пропущены дубликаты: ${skipped.join(', ')}`, 'warn');
return;
}
siblings.splice(insertIdx, 0, ...moved);
for (const t of moved) t.catId = newCatId;
for (let i = 0; i < siblings.length; i++) {
const t = siblings[i];
if ((t.order ?? 0) !== i + 1) { t.order = i + 1; await dbPut(STORE_TAGS, t); }
}
if (skipped.length) showToast(`Пропущены дубликаты: ${skipped.join(', ')}`, 'warn');
await reloadData();
}
async function reorderCategory(targetCat, insertBefore) {
if (draggedCatId == null || targetCat.id === draggedCatId) return;
const list = categories.filter(c => c.id !== draggedCatId);
let insertIdx = list.findIndex(c => c.id === targetCat.id);
if (insertIdx === -1) insertIdx = list.length;
if (!insertBefore) insertIdx += 1;
const draggedCat = categories.find(c => c.id === draggedCatId);
if (!draggedCat) return;
list.splice(insertIdx, 0, draggedCat);
for (let i = 0; i < list.length; i++) {
const c = list[i];
if ((c.order ?? 0) !== i + 1) { c.order = i + 1; await dbPut(STORE_CATS, c); }
}
await reloadData();
}
function renderPanel() {
const catsBox = panelEl.querySelector('#th-cats');
const catSel = panelEl.querySelector('#th-tag-cat');
const prevVal = catSel.value;
catSel.innerHTML = '';
for (const c of categories) {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.name;
catSel.appendChild(o);
}
if (prevVal && categories.some(c => String(c.id) === String(prevVal))) catSel.value = prevVal;
catsBox.innerHTML = '';
if (!categories.length) {
catsBox.innerHTML = '<div class="hint">Нет категорий. Нажми «+ категория».</div>';
updateSelectionUI();
return;
}
const ta = getTextarea();
const currentSet = ta ? new Set(getCurrentTags(ta)) : new Set();
for (const cat of categories) {
const catEl = document.createElement('div');
catEl.className = 'cat';
catEl.dataset.catId = cat.id;
const catColor = cat.color || '';
const header = document.createElement('div');
header.className = 'cat-name';
const grip = document.createElement('span');
grip.className = 'grip'; grip.textContent = '⠿';
grip.title = 'перетащить категорию'; grip.draggable = true;
grip.addEventListener('dragstart', (e) => {
draggedCatId = cat.id; draggedTagIds = [];
catEl.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `cat:${cat.id}`);
});
grip.addEventListener('dragend', () => {
draggedCatId = null;
catEl.classList.remove('dragging');
clearCatIndicators();
});
const swatch = document.createElement('span');
swatch.className = 'swatch'; swatch.title = 'цвет категории';
swatch.style.background = catColor || 'transparent';
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = catColor || '#ffffff';
colorInput.style.cssText = 'position:absolute;opacity:0;pointer-events:none;width:0;height:0';
swatch.appendChild(colorInput);
swatch.addEventListener('click', (e) => { e.stopPropagation(); colorInput.click(); });
colorInput.addEventListener('input', () => { swatch.style.background = colorInput.value; });
colorInput.addEventListener('change', async () => {
const v = normalizeHex(colorInput.value);
cat.color = v;
await dbPut(STORE_CATS, cat);
renderPanel();
});
const title = document.createElement('span');
title.className = 'title'; title.textContent = cat.name;
title.style.color = catColor || '#f0ad4e';
const delCat = document.createElement('button');
delCat.textContent = '✖'; delCat.title = 'удалить категорию';
delCat.onclick = async (e) => {
e.stopPropagation();
if (!confirm(`Удалить категорию "${cat.name}" и все её теги?`)) return;
for (const t of tags.filter(t => t.catId === cat.id)) await dbDelete(STORE_TAGS, t.id);
await dbDelete(STORE_CATS, cat.id);
await reloadData();
};
header.appendChild(grip);
header.appendChild(swatch);
header.appendChild(title);
header.appendChild(delCat);
catEl.appendChild(header);
catEl.addEventListener('dragover', (e) => {
if (draggedTagIds.length) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
catEl.classList.add('drag-over');
}
});
catEl.addEventListener('dragleave', (e) => {
if (e.target === catEl) catEl.classList.remove('drag-over');
});
catEl.addEventListener('drop', async (e) => {
if (!draggedTagIds.length) return;
e.preventDefault();
catEl.classList.remove('drag-over');
await reorderTagGroup({ id: -1, catId: cat.id, name: '' }, false);
draggedTagIds = [];
clearTagIndicators();
});
const catTags = tags
.filter(t => t.catId === cat.id)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
if (!catTags.length) {
const empty = document.createElement('div');
empty.className = 'hint'; empty.textContent = '—';
catEl.appendChild(empty);
}
for (const t of catTags) {
const row = document.createElement('div');
row.className = 'tag-row';
row.dataset.tagId = t.id;
row.draggable = false;
const eColor = effectiveColor(cat, t);
if (eColor) row.style.background = hexToRgba(eColor, 0.10);
const gripT = document.createElement('span');
gripT.className = 'grip'; gripT.textContent = '⠿';
gripT.title = 'перетащить тег'; gripT.draggable = true;
gripT.addEventListener('dragstart', (e) => {
if (selectedTagIds.has(t.id)) {
draggedTagIds = [...selectedTagIds];
} else {
draggedTagIds = [t.id];
selectedTagIds = new Set([t.id]);
updateSelectionUI();
}
draggedCatId = null;
panelEl.querySelectorAll('.tag-row').forEach(r => {
if (draggedTagIds.includes(Number(r.dataset.tagId))) r.classList.add('dragging');
});
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `taggroup:${draggedTagIds.join(',')}`);
e.stopPropagation();
});
gripT.addEventListener('dragend', () => {
draggedTagIds = [];
panelEl.querySelectorAll('.tag-row').forEach(r => r.classList.remove('dragging'));
clearTagIndicators();
});
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.dataset.tag = t.name;
cb.checked = currentSet.has(t.name);
cb.onchange = () => {
if (cb.checked) addTag(t.name); else removeTag(t.name);
};
const lbl = document.createElement('label');
lbl.textContent = t.name;
if (eColor) lbl.style.color = eColor;
lbl.onclick = (e) => {
e.preventDefault();
cb.checked = !cb.checked;
cb.onchange();
};
const del = document.createElement('span');
del.className = 'del'; del.textContent = '✖'; del.title = 'удалить тег';
del.onclick = async (e) => {
e.stopPropagation();
if (!confirm(`Удалить тег "${t.name}"?`)) return;
await dbDelete(STORE_TAGS, t.id);
tags = tags.filter(x => x.id !== t.id);
selectedTagIds.delete(t.id);
row.remove();
updateSelectionUI();
};
row.addEventListener('dragover', (e) => {
if (!draggedTagIds.length) return;
if (draggedTagIds.includes(t.id)) return;
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = row.getBoundingClientRect();
const isTop = (e.clientY - rect.top) < rect.height / 2;
row.classList.toggle('drag-over-top', isTop);
row.classList.toggle('drag-over-bottom', !isTop);
});
row.addEventListener('dragleave', (e) => {
if (e.target === row) row.classList.remove('drag-over-top', 'drag-over-bottom');
});
row.addEventListener('drop', async (e) => {
if (!draggedTagIds.length) return;
e.preventDefault(); e.stopPropagation();
const rect = row.getBoundingClientRect();
const isTop = (e.clientY - rect.top) < rect.height / 2;
row.classList.remove('drag-over-top', 'drag-over-bottom');
await reorderTagGroup(t, isTop);
draggedTagIds = [];
clearTagIndicators();
});
row.appendChild(gripT);
row.appendChild(cb);
row.appendChild(lbl);
row.appendChild(del);
catEl.appendChild(row);
}
header.addEventListener('dragover', (e) => {
if (draggedCatId == null || draggedCatId === cat.id) return;
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
catEl.classList.add('drag-over');
const rect = header.getBoundingClientRect();
catEl.dataset.dropBefore = ((e.clientY - rect.top) < rect.height / 2) ? '1' : '0';
});
header.addEventListener('dragleave', (e) => {
if (e.target === header) catEl.classList.remove('drag-over');
});
header.addEventListener('drop', async (e) => {
if (draggedCatId == null) return;
e.preventDefault(); e.stopPropagation();
const isTop = catEl.dataset.dropBefore === '1';
catEl.classList.remove('drag-over');
await reorderCategory(cat, isTop);
draggedCatId = null; clearCatIndicators();
});
catsBox.appendChild(catEl);
}
applySearchHighlight();
updateSelectionUI();
}
let _suppressSync = false;
function getTextarea() {
return document.querySelector('textarea#post_tags')
|| document.querySelector('textarea.tag-textarea')
|| document.querySelector('textarea.tag-textarea#post_tags');
}
function getCurrentTags(ta) {
return ta.value.split(/\s+/).map(t => t.trim()).filter(Boolean);
}
function setTags(ta, arr) {
_suppressSync = true;
ta.value = arr.join(' ');
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
_suppressSync = false;
}
function addTag(tag) {
const ta = getTextarea(); if (!ta) return;
const list = getCurrentTags(ta);
if (!list.includes(tag)) { list.push(tag); setTags(ta, list); }
}
function removeTag(tag) {
const ta = getTextarea(); if (!ta) return;
setTags(ta, getCurrentTags(ta).filter(t => t !== tag));
}
function watchTextarea() {
const ta = getTextarea();
if (!ta || ta.__th_watched) return;
ta.__th_watched = true;
const syncAll = () => {
if (_suppressSync || !panelEl) return;
const cur = new Set(getCurrentTags(ta));
panelEl.querySelectorAll('.tag-row input[type=checkbox]').forEach(cb => {
cb.checked = cur.has(cb.dataset.tag);
});
};
ta.addEventListener('input', syncAll);
ta.addEventListener('change', syncAll);
}
async function init() {
db = await openDB();
const cats = await dbGetAll(STORE_CATS);
if (cats.length === 0) {
await dbAdd(STORE_CATS, { name: 'Особенности тела', order: 1, color: '' });
await dbAdd(STORE_CATS, { name: 'NSFW', order: 2, color: '' });
await dbAdd(STORE_CATS, { name: 'Фон', order: 3, color: '' });
}
buildPanel();
await reloadData();
watchTextarea();
const allTags = await dbGetAll(STORE_TAGS);
const byCat = {};
for (const t of allTags) (byCat[t.catId] ||= []).push(t);
let needReload = false;
for (const catId of Object.keys(byCat)) {
const arr = byCat[catId].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
for (let i = 0; i < arr.length; i++) {
if ((arr[i].order ?? 0) !== i + 1) {
arr[i].order = i + 1;
await dbPut(STORE_TAGS, arr[i]);
needReload = true;
}
}
}
if (needReload) await reloadData();
setInterval(() => {
if (!panelEl) return;
const ta = getTextarea();
if (ta && !ta.__th_watched) {
watchTextarea();
const cur = new Set(getCurrentTags(ta));
panelEl.querySelectorAll('.tag-row input[type=checkbox]').forEach(cb => {
cb.checked = cur.has(cb.dataset.tag);
});
}
}, 500);
}
const wait = setInterval(() => {
if (document.body) {
clearInterval(wait);
init().catch(e => console.error('[taghelper]', e));
}
}, 200);
})();