Save named tag presets and combine them on the fly — auto-append your active preset(s) to every Gelbooru search.
// ==UserScript==
// @name Gelbooru Auto Tags
// @namespace http://tampermonkey.net/
// @version 4.0
// @description Save named tag presets and combine them on the fly — auto-append your active preset(s) to every Gelbooru search.
// @match *://*.gelbooru.com/*
// @run-at document-start
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// --- Storage keys ----------------------------------------------------
const STORAGE_ENABLED = 'gelbooru-auto-tags-enabled';
const STORAGE_PRESETS = 'gelbooru-auto-tags-presets';
const STORAGE_ACTIVE = 'gelbooru-auto-tags-active';
const STORAGE_LAST_APPLIED = 'gelbooru-auto-tags-last-applied';
const LEGACY_LIST = 'gelbooru-auto-tags-list';
const LEGACY_ENABLED = 'gelbooru-auto-sort-score-enabled';
function newId() {
return 'p_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
// --- Migration -------------------------------------------------------
function migrate() {
if (localStorage.getItem(STORAGE_PRESETS) !== null) return;
// From v2 (single tag list -> one preset called "Default")
const v2List = localStorage.getItem(LEGACY_LIST);
if (v2List) {
try {
const tags = JSON.parse(v2List);
if (Array.isArray(tags) && tags.length > 0) {
const id = newId();
localStorage.setItem(STORAGE_PRESETS, JSON.stringify([
{ id, name: 'Default', tags }
]));
localStorage.setItem(STORAGE_ACTIVE, JSON.stringify([id]));
return;
}
} catch (e) { /* ignore */ }
}
// Fresh install: seed with one preset
const id = newId();
localStorage.setItem(STORAGE_PRESETS, JSON.stringify([
{ id, name: 'Score sorted', tags: ['sort:score'] }
]));
localStorage.setItem(STORAGE_ACTIVE, JSON.stringify([id]));
// Carry over enable state from very old v1 if present
if (localStorage.getItem(LEGACY_ENABLED) !== null && localStorage.getItem(STORAGE_ENABLED) === null) {
localStorage.setItem(STORAGE_ENABLED, localStorage.getItem(LEGACY_ENABLED));
}
}
migrate();
// --- State accessors -------------------------------------------------
function isEnabled() {
const v = localStorage.getItem(STORAGE_ENABLED);
return v === null ? true : v === 'true';
}
function setEnabled(val) {
localStorage.setItem(STORAGE_ENABLED, val ? 'true' : 'false');
}
function getPresets() {
try {
const arr = JSON.parse(localStorage.getItem(STORAGE_PRESETS) || '[]');
if (!Array.isArray(arr)) return [];
return arr.filter(p =>
p && typeof p.id === 'string' &&
typeof p.name === 'string' &&
Array.isArray(p.tags)
);
} catch (e) {
return [];
}
}
function setPresets(presets) {
localStorage.setItem(STORAGE_PRESETS, JSON.stringify(presets));
}
// Active preset IDs are stored as a JSON array. We also accept a bare
// string for backwards compatibility with v3.x (single active preset).
function getActiveIds() {
const raw = localStorage.getItem(STORAGE_ACTIVE);
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return arr.filter(x => typeof x === 'string');
} catch (e) { /* fall through to legacy single-string format */ }
return [raw];
}
function setActiveIds(ids) {
if (!ids || ids.length === 0) {
localStorage.removeItem(STORAGE_ACTIVE);
} else {
localStorage.setItem(STORAGE_ACTIVE, JSON.stringify(ids));
}
}
function isActive(id) {
return getActiveIds().includes(id);
}
function getActivePresets() {
const ids = getActiveIds();
if (ids.length === 0) return [];
const presets = getPresets();
return ids.map(id => presets.find(p => p.id === id)).filter(Boolean);
}
// Combined tags from every active preset, deduplicated case-insensitively,
// preserving the order presets were activated in.
function getActiveTags() {
const tags = [];
const seen = new Set();
for (const preset of getActivePresets()) {
for (const tag of preset.tags) {
const key = (tag || '').trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
}
return tags;
}
// Tags that the script last auto-added to a search.
// Used to remove the previous preset's tags when switching presets.
function getLastApplied() {
try {
const arr = JSON.parse(localStorage.getItem(STORAGE_LAST_APPLIED) || '[]');
return Array.isArray(arr) ? arr.filter(t => typeof t === 'string') : [];
} catch (e) {
return [];
}
}
function setLastApplied(tags) {
localStorage.setItem(STORAGE_LAST_APPLIED, JSON.stringify(tags || []));
}
// --- Tag logic -------------------------------------------------------
function tokenize(tags) {
return (tags || '').split(/[\s+]+/).filter(Boolean);
}
function hasExactTag(tags, tag) {
const t = tag.toLowerCase();
return tokenize(tags).some(x => x.toLowerCase() === t);
}
function hasSortDirective(tags) {
return tokenize(tags).some(x => /^sort:/i.test(x));
}
// Apply auto-tags to a search string:
// 1. Remove tags that the previous preset added but aren't in the current preset.
// 2. Add the current preset's tags (skipping ones already present).
function applyAutoTags(tags) {
let result = tags || '';
const activeTags = getActiveTags();
const activeSet = new Set(activeTags.map(t => t.toLowerCase()));
// Step 1: clean up tags from the previous preset that the new preset doesn't want
const lastApplied = getLastApplied();
const toRemove = lastApplied.filter(t => !activeSet.has(t.toLowerCase()));
if (toRemove.length > 0) {
const removeSet = new Set(toRemove.map(t => t.toLowerCase()));
const sep = result.includes('+') ? '+' : ' ';
result = tokenize(result).filter(t => !removeSet.has(t.toLowerCase())).join(sep);
}
// Step 2: add tags from the current preset
for (const tag of activeTags) {
const trimmed = (tag || '').trim();
if (!trimmed) continue;
if (/^sort:/i.test(trimmed) && hasSortDirective(result)) continue;
if (hasExactTag(result, trimmed)) continue;
const sep = result === '' ? '' : (result.includes('+') ? '+' : ' ');
result = result + sep + trimmed;
}
return result;
}
// --- Core behavior ---------------------------------------------------
function fixCurrentUrl() {
if (!isEnabled()) return;
const params = new URLSearchParams(window.location.search);
if (params.get('page') !== 'post' || params.get('s') !== 'list') return;
const tags = params.get('tags') || '';
const updated = applyAutoTags(tags);
// Record what we just applied so the next preset switch knows what to clean up
setLastApplied(getActiveTags());
if (updated === tags) return;
params.set('tags', updated);
const newUrl = window.location.pathname + '?' + params.toString() + window.location.hash;
window.location.replace(newUrl);
}
function hookSearchForms() {
document.addEventListener('submit', function (e) {
if (!isEnabled()) return;
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
const tagInput = form.querySelector('input[name="tags"]');
if (!tagInput) return;
tagInput.value = applyAutoTags(tagInput.value);
setLastApplied(getActiveTags());
}, true);
}
// --- UI --------------------------------------------------------------
let editingDraft = null; // { id: string|null, name: string, tags: string[] }
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.slice(0, max - 1) + '…' : str;
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
#gb-at-root, #gb-at-root * { box-sizing: border-box; }
#gb-at-pill {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 99999;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(20, 20, 28, 0.92);
color: #e8e8ec;
font-family: system-ui, -apple-system, sans-serif;
font-size: 12px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 999px;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
user-select: none;
opacity: 0.85;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#gb-at-pill:hover { opacity: 1; transform: translateY(-1px); }
#gb-at-pill .gb-at-toggle {
position: relative;
width: 30px;
height: 16px;
background: #555;
border-radius: 999px;
transition: background 0.2s ease;
flex-shrink: 0;
cursor: pointer;
}
#gb-at-pill .gb-at-toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 12px; height: 12px;
background: #fff;
border-radius: 50%;
transition: left 0.2s ease;
}
#gb-at-pill.on .gb-at-toggle { background: #4ea1ff; }
#gb-at-pill.on .gb-at-toggle::after { left: 16px; }
#gb-at-pill .gb-at-sep { opacity: 0.35; }
#gb-at-pill .gb-at-active-name {
font-weight: 600;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#gb-at-pill .gb-at-active-name.muted { font-weight: 400; opacity: 0.55; font-style: italic; }
#gb-at-pill .gb-at-count { opacity: 0.6; font-size: 11px; }
#gb-at-pill .gb-at-gear {
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
opacity: 0.7;
font-size: 14px;
line-height: 1;
}
#gb-at-pill .gb-at-gear:hover {
opacity: 1;
background: rgba(255,255,255,0.08);
}
#gb-at-panel {
position: fixed;
bottom: 64px;
right: 16px;
z-index: 99999;
width: 320px;
max-height: calc(100vh - 96px);
overflow-y: auto;
padding: 14px;
background: rgba(24, 24, 32, 0.98);
color: #e8e8ec;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: none;
}
#gb-at-panel.open { display: block; }
#gb-at-panel .gb-at-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-weight: 600;
}
#gb-at-panel .gb-at-close {
cursor: pointer;
opacity: 0.6;
padding: 0 6px;
font-size: 18px;
line-height: 1;
}
#gb-at-panel .gb-at-close:hover { opacity: 1; }
#gb-at-panel .gb-at-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
margin: 6px 0 6px;
}
/* Preset list rows */
#gb-at-panel .gb-at-presets {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
#gb-at-panel .gb-at-preset {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
#gb-at-panel .gb-at-preset:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.15);
}
#gb-at-panel .gb-at-preset.active {
background: rgba(78, 161, 255, 0.12);
border-color: rgba(78, 161, 255, 0.5);
}
#gb-at-panel .gb-at-checkbox {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 4px;
flex-shrink: 0;
position: relative;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
#gb-at-panel .gb-at-checkbox:hover {
border-color: rgba(255,255,255,0.55);
}
#gb-at-panel .gb-at-preset.active .gb-at-checkbox {
border-color: #4ea1ff;
background: #4ea1ff;
}
#gb-at-panel .gb-at-preset.active .gb-at-checkbox::after {
content: '';
position: absolute;
left: 3px; top: 0px;
width: 4px; height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
#gb-at-panel .gb-at-preset-info {
flex: 1;
min-width: 0;
}
#gb-at-panel .gb-at-preset-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#gb-at-panel .gb-at-preset-tags {
font-size: 11px;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
#gb-at-panel .gb-at-preset-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
#gb-at-panel .gb-at-icon-btn {
cursor: pointer;
width: 24px; height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
opacity: 0.6;
font-size: 13px;
}
#gb-at-panel .gb-at-icon-btn:hover {
opacity: 1;
background: rgba(255,255,255,0.1);
}
#gb-at-panel .gb-at-empty {
opacity: 0.4;
font-style: italic;
font-size: 12px;
padding: 8px 0;
text-align: center;
}
/* Edit form */
#gb-at-panel .gb-at-name-input {
width: 100%;
padding: 8px 10px;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
font-family: inherit;
outline: none;
margin-bottom: 12px;
}
#gb-at-panel .gb-at-name-input:focus {
border-color: rgba(78, 161, 255, 0.6);
}
#gb-at-panel .gb-at-chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
min-height: 24px;
margin-bottom: 10px;
}
#gb-at-panel .gb-at-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 4px 3px 9px;
background: rgba(78, 161, 255, 0.18);
border: 1px solid rgba(78, 161, 255, 0.35);
border-radius: 999px;
font-size: 12px;
max-width: 100%;
word-break: break-all;
}
#gb-at-panel .gb-at-chip-x {
cursor: pointer;
width: 16px; height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0.6;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
#gb-at-panel .gb-at-chip-x:hover {
opacity: 1;
background: rgba(255,255,255,0.1);
}
#gb-at-panel .gb-at-input-row {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
#gb-at-panel input.gb-at-tag-input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #e8e8ec;
font-size: 12px;
font-family: inherit;
outline: none;
}
#gb-at-panel input.gb-at-tag-input:focus {
border-color: rgba(78, 161, 255, 0.6);
}
#gb-at-panel .gb-at-btn-row {
display: flex;
justify-content: space-between;
gap: 6px;
margin-top: 10px;
}
#gb-at-panel button.gb-at-btn {
padding: 7px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #e8e8ec;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
#gb-at-panel button.gb-at-btn:hover { background: rgba(255,255,255,0.12); }
#gb-at-panel button.gb-at-btn.primary {
background: #4ea1ff;
border-color: #4ea1ff;
color: #fff;
}
#gb-at-panel button.gb-at-btn.primary:hover { background: #6cb4ff; border-color: #6cb4ff; }
#gb-at-panel button.gb-at-btn.full { width: 100%; }
#gb-at-panel button.gb-at-btn.add {
padding: 6px 12px;
background: #4ea1ff;
border-color: #4ea1ff;
color: #fff;
}
#gb-at-panel button.gb-at-btn.add:hover { background: #6cb4ff; }
#gb-at-panel .gb-at-hint {
margin-top: 8px;
font-size: 11px;
opacity: 0.45;
line-height: 1.4;
}
#gb-at-panel .gb-at-hint code {
background: rgba(255,255,255,0.07);
padding: 1px 4px;
border-radius: 3px;
font-size: 10px;
}
`;
document.head.appendChild(style);
}
function buildPanelListView() {
const presets = getPresets();
let html = `
<div class="gb-at-header">
<span>Auto-tag presets</span>
<span class="gb-at-close" data-action="close">×</span>
</div>
<div class="gb-at-label">Click a preset to use only it · Use the checkbox to combine</div>
<div class="gb-at-presets">
`;
if (presets.length === 0) {
html += `<div class="gb-at-empty">No presets yet — create one below.</div>`;
} else {
for (const p of presets) {
const active = isActive(p.id);
html += `
<div class="gb-at-preset ${active ? 'active' : ''}" data-action="solo" data-preset-id="${p.id}">
<div class="gb-at-checkbox" data-action="toggle-active" data-preset-id="${p.id}" title="Toggle in combination"></div>
<div class="gb-at-preset-info">
<div class="gb-at-preset-name"></div>
<div class="gb-at-preset-tags"></div>
</div>
<div class="gb-at-preset-actions">
<span class="gb-at-icon-btn" data-action="edit" data-preset-id="${p.id}" title="Edit">✏</span>
<span class="gb-at-icon-btn" data-action="delete" data-preset-id="${p.id}" title="Delete">🗑</span>
</div>
</div>
`;
}
}
html += `
</div>
<button class="gb-at-btn full primary" data-action="new">+ New preset</button>
<div class="gb-at-hint">Click a row to use only that preset. Click the checkbox on the left to add or remove a preset from the active mix without affecting the others.</div>
`;
return html;
}
function buildPanelEditView() {
const isNew = editingDraft.id === null;
return `
<div class="gb-at-header">
<span>${isNew ? 'New preset' : 'Edit preset'}</span>
<span class="gb-at-close" data-action="cancel-edit">×</span>
</div>
<div class="gb-at-label">Preset name</div>
<input class="gb-at-name-input" type="text" placeholder="e.g. Glasses mode" value="">
<div class="gb-at-label">Tags</div>
<div class="gb-at-chips"></div>
<div class="gb-at-input-row">
<input class="gb-at-tag-input" type="text" placeholder="add a tag…">
<button class="gb-at-btn add" data-action="add-tag">Add</button>
</div>
<div class="gb-at-btn-row">
<button class="gb-at-btn" data-action="cancel-edit">Cancel</button>
<button class="gb-at-btn primary" data-action="save">Save</button>
</div>
<div class="gb-at-hint">Press Enter in the tag box to add. Multiple tags can be separated by spaces.</div>
`;
}
function injectUI() {
if (document.getElementById('gb-at-root')) return;
injectStyles();
const root = document.createElement('div');
root.id = 'gb-at-root';
root.innerHTML = `
<div id="gb-at-pill" title="Auto-tags">
<span class="gb-at-toggle" data-action="toggle"></span>
<span class="gb-at-active-name"></span>
<span class="gb-at-count"></span>
<span class="gb-at-sep">|</span>
<span class="gb-at-gear" data-action="open" title="Manage presets">⚙</span>
</div>
<div id="gb-at-panel"></div>
`;
document.body.appendChild(root);
const pill = root.querySelector('#gb-at-pill');
const panel = root.querySelector('#gb-at-panel');
const nameEl = pill.querySelector('.gb-at-active-name');
const countEl = pill.querySelector('.gb-at-count');
function renderPill() {
const on = isEnabled();
const activePresets = getActivePresets();
pill.classList.toggle('on', on);
if (activePresets.length === 0) {
nameEl.textContent = 'no preset';
nameEl.classList.add('muted');
countEl.textContent = '';
} else if (activePresets.length === 1) {
nameEl.textContent = truncate(activePresets[0].name, 18);
nameEl.classList.remove('muted');
const tagCount = getActiveTags().length;
countEl.textContent = tagCount > 0 ? `(${tagCount})` : '';
} else {
const first = activePresets[0].name;
const more = activePresets.length - 1;
nameEl.textContent = `${truncate(first, 12)} +${more}`;
nameEl.classList.remove('muted');
const tagCount = getActiveTags().length;
countEl.textContent = tagCount > 0 ? `(${tagCount})` : '';
}
}
function renderPanel() {
if (editingDraft) {
panel.innerHTML = buildPanelEditView();
const nameInput = panel.querySelector('.gb-at-name-input');
nameInput.value = editingDraft.name;
nameInput.addEventListener('input', () => {
editingDraft.name = nameInput.value;
});
renderEditChips();
const tagInput = panel.querySelector('.gb-at-tag-input');
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTagFromInput(tagInput);
}
});
setTimeout(() => nameInput.focus(), 0);
} else {
panel.innerHTML = buildPanelListView();
const presets = getPresets();
const rows = panel.querySelectorAll('.gb-at-preset');
rows.forEach((row, i) => {
const p = presets[i];
if (!p) return;
row.querySelector('.gb-at-preset-name').textContent = p.name || '(unnamed)';
const tagPreview = p.tags.length > 0 ? p.tags.join(', ') : '(no tags)';
row.querySelector('.gb-at-preset-tags').textContent = tagPreview;
});
}
}
function renderEditChips() {
const chipsEl = panel.querySelector('.gb-at-chips');
chipsEl.innerHTML = '';
if (editingDraft.tags.length === 0) {
const empty = document.createElement('span');
empty.className = 'gb-at-empty';
empty.style.padding = '0';
empty.textContent = 'No tags yet.';
chipsEl.appendChild(empty);
return;
}
for (const tag of editingDraft.tags) {
const chip = document.createElement('span');
chip.className = 'gb-at-chip';
const text = document.createElement('span');
text.textContent = tag;
const x = document.createElement('span');
x.className = 'gb-at-chip-x';
x.title = 'Remove';
x.textContent = '×';
x.addEventListener('click', () => {
editingDraft.tags = editingDraft.tags.filter(t => t !== tag);
renderEditChips();
});
chip.appendChild(text);
chip.appendChild(x);
chipsEl.appendChild(chip);
}
}
function addTagFromInput(input) {
const raw = input.value.trim();
if (!raw) return;
const incoming = raw.split(/\s+/).filter(Boolean);
const seen = new Set(editingDraft.tags.map(t => t.toLowerCase()));
for (const t of incoming) {
if (!seen.has(t.toLowerCase())) {
editingDraft.tags.push(t);
seen.add(t.toLowerCase());
}
}
input.value = '';
renderEditChips();
}
function openPanel() {
editingDraft = null;
renderPanel();
panel.classList.add('open');
}
function closePanel() {
panel.classList.remove('open');
editingDraft = null;
}
function startEdit(presetId) {
const p = getPresets().find(x => x.id === presetId);
if (!p) return;
editingDraft = { id: p.id, name: p.name, tags: p.tags.slice() };
renderPanel();
}
function startNew() {
editingDraft = { id: null, name: '', tags: [] };
renderPanel();
}
function saveDraft() {
const name = (editingDraft.name || '').trim();
if (!name) {
alert('Please give the preset a name.');
return;
}
const presets = getPresets();
if (editingDraft.id === null) {
const id = newId();
presets.push({ id, name, tags: editingDraft.tags.slice() });
setPresets(presets);
// If nothing was active, make this new preset active
if (getActiveIds().length === 0) setActiveIds([id]);
} else {
const idx = presets.findIndex(p => p.id === editingDraft.id);
if (idx >= 0) {
presets[idx] = { id: editingDraft.id, name, tags: editingDraft.tags.slice() };
setPresets(presets);
}
}
editingDraft = null;
renderPanel();
renderPill();
fixCurrentUrl();
}
function cancelEdit() {
editingDraft = null;
renderPanel();
}
function deletePreset(presetId) {
const p = getPresets().find(x => x.id === presetId);
if (!p) return;
if (!confirm(`Delete preset "${p.name}"?`)) return;
const remaining = getPresets().filter(x => x.id !== presetId);
setPresets(remaining);
// Drop the deleted preset from the active list
const newActive = getActiveIds().filter(id => id !== presetId);
setActiveIds(newActive);
renderPanel();
renderPill();
fixCurrentUrl();
}
function soloPreset(presetId) {
// "Use only this preset" — replace the active list with just this one
setActiveIds([presetId]);
renderPill();
renderPanel();
fixCurrentUrl();
}
function toggleActive(presetId) {
// Add or remove this preset from the active list without affecting others
const ids = getActiveIds();
if (ids.includes(presetId)) {
setActiveIds(ids.filter(id => id !== presetId));
} else {
setActiveIds(ids.concat(presetId));
}
renderPill();
renderPanel();
fixCurrentUrl();
}
// Event delegation
root.addEventListener('click', (e) => {
let el = e.target;
while (el && el !== root && !(el.dataset && el.dataset.action)) {
el = el.parentElement;
}
if (!el || el === root) return;
const action = el.dataset.action;
const presetId = el.dataset.presetId;
if (action !== 'solo') e.stopPropagation();
switch (action) {
case 'toggle': {
const on = !isEnabled();
setEnabled(on);
renderPill();
if (on) fixCurrentUrl();
break;
}
case 'open':
if (panel.classList.contains('open')) closePanel();
else openPanel();
break;
case 'close':
closePanel();
break;
case 'solo':
if (presetId) soloPreset(presetId);
break;
case 'toggle-active':
if (presetId) toggleActive(presetId);
break;
case 'edit':
if (presetId) startEdit(presetId);
break;
case 'delete':
if (presetId) deletePreset(presetId);
break;
case 'new':
startNew();
break;
case 'save':
saveDraft();
break;
case 'cancel-edit':
cancelEdit();
break;
case 'add-tag': {
const input = panel.querySelector('.gb-at-tag-input');
if (input) addTagFromInput(input);
break;
}
}
});
// Click outside panel to close it.
// We use composedPath() instead of contains(e.target) because some
// in-panel handlers (e.g. removing a tag chip) re-render the panel,
// detaching e.target from the DOM before this handler runs. The path
// is captured at dispatch time, so it's still accurate.
document.addEventListener('click', (e) => {
if (!panel.classList.contains('open')) return;
const path = e.composedPath();
if (path.includes(panel) || path.includes(pill)) return;
closePanel();
});
renderPill();
}
// --- Init ------------------------------------------------------------
fixCurrentUrl();
function onReady() {
hookSearchForms();
injectUI();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady);
} else {
onReady();
}
})();