Adds checkboxes to tags on Danbooru post pages and search result pages. UI in English. Copy button transforms to checkmark on success.
// ==UserScript==
// @name Danbooru Tag Selector & Exporter
// @name:en Danbooru Tag Selector & Exporter
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Adds checkboxes to tags on Danbooru post pages and search result pages. UI in English. Copy button transforms to checkmark on success.
// @description:en Adds checkboxes to tags on Danbooru post pages and search result pages. UI in English. Copy button transforms to checkmark on success.
// @author Your Name
// @match https://danbooru.donmai.us/posts*
// @match https://danbooru.donmai.us/
// @grant GM_setClipboard
// @grant GM_addStyle
// @run-at document-idl
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const EXPORT_SEPARATOR = ', '; // Separator for formatted tags
const INITIAL_DELAY_MS = 300;
const BUTTON_ANIMATION_DURATION_MS = 300; // Duration of text/checkmark fade animations
const CHECKMARK_STAY_DURATION_MS = 1200; // How long the checkmark stays fully visible
const SUCCESS_BG_COLOR = '#3e8e41'; // Slightly darker green for success feedback on button
// --- End Configuration ---
let uiContainer = null;
let outputTextarea = null;
let tagObserver = null;
function addStyles() {
GM_addStyle(`
#tag-exporter-ui {
background-color: #282a2e;
color: #c8c8c8;
padding: 10px;
margin: 15px 0;
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
position: relative;
}
#tag-exporter-ui h3 {
margin-top: 0;
margin-bottom: 8px;
color: #fff;
border-bottom: 1px solid #555;
padding-bottom: 5px;
font-size: 1.05em;
}
#tag-exporter-ui button {
background-color: #4CAF50; /* Default green */
color: white;
border: none;
padding: 5px 10px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 12px;
margin: 3px 2px;
cursor: pointer;
border-radius: 3px;
transition: background-color ${BUTTON_ANIMATION_DURATION_MS / 1000}s ease-out;
vertical-align: middle;
position: relative; /* For positioning child spans */
overflow: hidden; /* Hide overflowing content during transitions if any */
}
#tag-exporter-ui button:hover {
background-color: #45a049;
}
#tag-exporter-ui button.secondary {
background-color: #555;
}
#tag-exporter-ui button.secondary:hover {
background-color: #666;
}
/* Styles for the copy button with feedback */
#tag-exporter-ui button.copy-feedback-button .button-original-text,
#tag-exporter-ui button.copy-feedback-button .button-checkmark-icon {
display: flex;
align-items: center;
justify-content: center;
transition: opacity ${BUTTON_ANIMATION_DURATION_MS / 1000}s ease-out;
width: 100%;
height: 100%;
}
#tag-exporter-ui button.copy-feedback-button .button-checkmark-icon {
opacity: 0;
font-size: 1.2em; /* Checkmark slightly larger */
color: white;
position: absolute;
top: 0;
left: 0;
pointer-events: none; /* So it doesn't interfere with button clicks */
}
/* Success state for the copy button */
#tag-exporter-ui button.copy-feedback-button.copy-button-success {
background-color: ${SUCCESS_BG_COLOR} !important;
}
#tag-exporter-ui button.copy-feedback-button.copy-button-success .button-original-text {
opacity: 0;
}
#tag-exporter-ui button.copy-feedback-button.copy-button-success .button-checkmark-icon {
opacity: 1;
}
.tag-checkbox-item {
margin-right: 7px !important;
vertical-align: middle;
flex-shrink: 0;
transform: scale(1.0);
}
#exported-tags-output {
width: 100%;
min-height: 35px;
margin-top: 6px;
padding: 4px;
background-color: #1e1f22;
color: #c8c8c8;
border: 1px solid #444;
border-radius: 3px;
font-family: monospace;
font-size: 0.85em;
box-sizing: border-box;
}
section#tag-list ul li[data-tag-name],
section#tag-box ul.search-tag-list li[data-tag-name] {
margin-bottom: 1px !important;
}
`);
}
function getSelectedRawTags() {
const selectedCheckboxes = document.querySelectorAll('.tag-checkbox-item:checked');
const tags = [];
selectedCheckboxes.forEach(checkbox => {
if (checkbox.dataset.tagName) {
tags.push(checkbox.dataset.tagName);
}
});
return tags;
}
function getFormattedTagString(rawTagsArray) {
if (!rawTagsArray || rawTagsArray.length === 0) {
return "";
}
const processedTags = rawTagsArray.map(tag => tag.replace(/_/g, ' '));
return processedTags.join(EXPORT_SEPARATOR);
}
function updateOutputTextarea() {
if (outputTextarea) {
const rawTags = getSelectedRawTags();
outputTextarea.value = getFormattedTagString(rawTags);
}
}
function copyToClipboard() {
const button = this; // 'this' refers to the clicked button
if (button.classList.contains('copy-in-progress')) {
return; // Prevent multiple clicks during animation
}
const rawTags = getSelectedRawTags();
if (rawTags.length === 0) {
console.log('Danbooru Tag Exporter: No tags selected to copy.');
// Optionally: add a brief failure animation (e.g., button shake)
return;
}
button.classList.add('copy-in-progress');
const originalMinHeight = button.style.minHeight;
const originalMinWidth = button.style.minWidth;
button.style.minHeight = button.offsetHeight + 'px'; // Maintain size during content change
button.style.minWidth = button.offsetWidth + 'px';
const formattedTagString = getFormattedTagString(rawTags);
GM_setClipboard(formattedTagString);
if(outputTextarea) outputTextarea.value = formattedTagString;
console.log(`Danbooru Tag Exporter: Copied ${rawTags.length} tag${rawTags.length === 1 ? "" : "s"} to clipboard.`);
// Add success class to trigger animations (text fade out, checkmark fade in, bg change)
button.classList.add('copy-button-success');
// After checkmark is visible for a duration, revert to original state
setTimeout(() => {
button.classList.remove('copy-button-success'); // Triggers animations back
}, BUTTON_ANIMATION_DURATION_MS + CHECKMARK_STAY_DURATION_MS); // Time for fade-in + stay
// After all animations are complete, remove the progress lock and reset min-size
setTimeout(() => {
button.classList.remove('copy-in-progress');
button.style.minHeight = originalMinHeight;
button.style.minWidth = originalMinWidth;
}, BUTTON_ANIMATION_DURATION_MS + CHECKMARK_STAY_DURATION_MS + BUTTON_ANIMATION_DURATION_MS); // Total animation cycle time
}
function selectAllTags(select) {
const allCheckboxes = document.querySelectorAll('.tag-checkbox-item');
allCheckboxes.forEach(checkbox => {
checkbox.checked = select;
});
updateOutputTextarea();
}
function insertCheckbox(listItem, tagName) {
if (listItem.querySelector(':scope > .tag-checkbox-item')) {
return;
}
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'tag-checkbox-item';
checkbox.dataset.tagName = tagName;
checkbox.addEventListener('change', updateOutputTextarea);
listItem.insertBefore(checkbox, listItem.firstChild);
}
function processTagsInList(containerElement, isDirectUlContainer = false) {
let tagListItems;
if (isDirectUlContainer) {
tagListItems = containerElement.querySelectorAll(':scope > li[data-tag-name]');
} else {
tagListItems = containerElement.querySelectorAll('ul > li[data-tag-name]');
}
if (tagListItems.length === 0) return false;
let FPU_checkboxAdded = false;
tagListItems.forEach(li => {
if (li.querySelector(':scope > .tag-checkbox-item')) {
FPU_checkboxAdded = true; return;
}
const tagName = li.dataset.tagName;
if (tagName) {
insertCheckbox(li, tagName);
FPU_checkboxAdded = true;
}
});
if (FPU_checkboxAdded) updateOutputTextarea();
return FPU_checkboxAdded;
}
function addCheckboxesToTags() {
let tagsFoundAndProcessed = false;
const singlePostTagListSection = document.querySelector('section#tag-list');
if (singlePostTagListSection) {
const categorizedTagListDiv = singlePostTagListSection.querySelector('div.tag-list.categorized-tag-list');
if (processTagsInList(categorizedTagListDiv || singlePostTagListSection, !categorizedTagListDiv)) {
tagsFoundAndProcessed = true;
}
}
const searchPageTagUl = document.querySelector('aside#sidebar section#tag-box ul.search-tag-list');
if (searchPageTagUl && processTagsInList(searchPageTagUl, true)) {
tagsFoundAndProcessed = true;
}
return tagsFoundAndProcessed;
}
function createExportUI() {
if (document.getElementById('tag-exporter-ui')) {
if (addCheckboxesToTags()) updateOutputTextarea();
return;
}
uiContainer = document.createElement('div');
uiContainer.id = 'tag-exporter-ui';
const title = document.createElement('h3');
title.textContent = 'Tag Selector & Exporter';
uiContainer.appendChild(title);
const copyButton = document.createElement('button');
copyButton.className = 'copy-feedback-button'; // Class for special styling & JS targeting
copyButton.innerHTML = `<span class="button-original-text">Copy Selected</span><span class="button-checkmark-icon">√</span>`;
copyButton.title = `Formats tags (e.g., 'tag_name' to 'tag name') and copies them, separated by '${EXPORT_SEPARATOR.trim()}'.`;
copyButton.addEventListener('click', copyToClipboard);
uiContainer.appendChild(copyButton);
const selectAllButton = document.createElement('button');
selectAllButton.textContent = 'Select All';
selectAllButton.className = 'secondary';
selectAllButton.addEventListener('click', () => selectAllTags(true));
uiContainer.appendChild(selectAllButton);
const deselectAllButton = document.createElement('button');
deselectAllButton.textContent = 'Deselect All';
deselectAllButton.className = 'secondary';
deselectAllButton.addEventListener('click', () => selectAllTags(false));
uiContainer.appendChild(deselectAllButton);
outputTextarea = document.createElement('textarea');
outputTextarea.id = 'exported-tags-output';
outputTextarea.readOnly = true;
outputTextarea.placeholder = 'Selected tags will appear here...';
uiContainer.appendChild(outputTextarea);
const singlePostTagListArea = document.querySelector('section#tag-list');
const sidebarArea = document.getElementById('sidebar');
if (singlePostTagListArea) {
singlePostTagListArea.parentNode.insertBefore(uiContainer, singlePostTagListArea);
} else if (sidebarArea) {
sidebarArea.insertBefore(uiContainer, sidebarArea.firstChild);
} else {
document.body.insertBefore(uiContainer, document.body.firstChild);
}
if (addCheckboxesToTags()) updateOutputTextarea();
}
function startTagObserver() {
let observerTarget = null;
const singlePostPrimaryTarget = document.querySelector('section#tag-list div.tag-list.categorized-tag-list');
const singlePostFallbackTarget = document.querySelector('section#tag-list');
const searchPageTarget = document.querySelector('aside#sidebar section#tag-box ul.search-tag-list');
if (singlePostPrimaryTarget) observerTarget = singlePostPrimaryTarget;
else if (singlePostFallbackTarget) observerTarget = singlePostFallbackTarget;
else if (searchPageTarget) observerTarget = searchPageTarget;
if (!observerTarget) return;
if (tagObserver) tagObserver.disconnect();
tagObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
addCheckboxesToTags(); break;
}
}
});
tagObserver.observe(observerTarget, { childList: true, subtree: true });
}
function mainInit() {
addStyles();
createExportUI();
if (addCheckboxesToTags()) updateOutputTextarea();
startTagObserver();
}
document.addEventListener('turbo:load', () => setTimeout(mainInit, INITIAL_DELAY_MS));
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(mainInit, INITIAL_DELAY_MS);
} else {
document.addEventListener('DOMContentLoaded', () => setTimeout(mainInit, INITIAL_DELAY_MS));
}
})();