// ==UserScript==
// @name Booru Tags Hauler
// @name:ru Booru Tags Hauler
// @namespace https://github.com/vanja-san/JS-UserScripts/main/scripts/Booru%20Tags%20Hauler
// @version 1.9.9
// @description Adds a 'Copy all tags' button to the thumbnail hover preview tooltip. Copy all of a tooltip tags instantly!
// @description:ru Добавляет кнопку 'Скопировать все теги' во всплывающую подсказку при наведении на превью. Копируйте все теги картинки, не открывая её страницу! Существенная экономия времени.
// @author vanja-san
// @license MIT
// @match https://danbooru.donmai.us/*
// @match https://safebooru.donmai.us/*
// @match https://hijiribe.donmai.us/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=donmai.us
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
"use strict";
// ===== MODERN LOCALIZATION SYSTEM =====
const localization = {
en: {
title: "Copy all tags",
notification: "Tags copied to clipboard!",
settings: "Settings",
formatting: "Tag Formatting",
addCommas: "Add commas between tags",
escapeParentheses: "Escape parentheses (\\( \\))",
escapeColons: "Escape colons (\\:)",
replaceUnderscores: "Replace Underscores",
languageSettings: "Language Settings",
language: "Language:",
langAuto: "System default",
langEn: "English",
langRu: "Russian",
buttonText: "Copy tags",
saveButton: "Save",
savedButton: "Saved!",
cancelButton: "Close",
savedNotification: "Settings saved!",
reloadNotice: "PLEASE NOTE: Page reload required for language change",
},
ru: {
title: "Скопировать все теги",
notification: "Теги скопированы в буфер!",
settings: "Настройки",
formatting: "Форматирование тегов",
addCommas: "Добавлять запятые между тегами",
escapeParentheses: "Экранировать скобки (\\( \\))",
escapeColons: "Экранировать двоеточия (\\:)",
replaceUnderscores: "Заменять нижнии подчеркивания пробелами",
languageSettings: "Настройки языка",
language: "Язык:",
langAuto: "Как в системе",
langEn: "Английский",
langRu: "Русский",
buttonText: "Скопировать теги",
saveButton: "Сохранить",
savedButton: "Сохранено!",
cancelButton: "Закрыть",
savedNotification: "Настройки сохранены!",
reloadNotice: "ВНИМАНИЕ: Для смены языка требуется перезагрузка страницы",
}
};
// ===== CORE SETTINGS =====
const DEFAULT_SETTINGS = {
addCommas: true,
escapeParentheses: true,
escapeColons: false,
replaceUnderscores: true,
language: "auto"
};
const SETTINGS = {
...DEFAULT_SETTINGS,
...GM_getValue("tagCopierSettings", {}),
};
// ===== DYNAMIC LANGUAGE MANAGEMENT =====
let currentLang = "en";
function updateLanguage() {
if (SETTINGS.language === "auto") {
const systemLang = navigator.language.toLowerCase();
currentLang = systemLang.startsWith("ru") ? "ru" : "en";
} else {
currentLang = SETTINGS.language;
}
return currentLang;
}
function t(key) {
const lang = updateLanguage();
return localization[lang]?.[key] || localization.en[key] || key;
}
// Initialize language
updateLanguage();
// ===== STYLE MANAGEMENT =====
function applyGlobalStyles() {
GM_addStyle(`
.tag-copy-btn {
display: flex;
background: rgba(0, 0, 0, 0.7) !important;
color: var(--muted-text-color);
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10000;
border: none !important;
border-radius: 4px;
padding: 0;
width: 28px;
height: 28px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
gap: .2rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out, background 0.2s ease-in-out, color 0.2s ease-in-out;
will-change: opacity;
}
.tag-copy-btn.visible {
opacity: 1;
pointer-events: auto;
}
.tag-copy-btn.with-text {
width: auto;
}
.tag-copy-btn.with-text-left {
justify-content: flex-start;
}
.tag-copy-btn.with-text-right {
justify-content: flex-end;
}
.tag-copy-btn:hover {
color: #4CAF50 !important;
background: rgba(0, 0, 0, 0.9) !important;
}
.tag-copy-btn.copied {
color: #2196F3 !important;
}
.tag-copy-btn svg {
height: .9rem;
stroke-width: 2;
flex-shrink: 0;
}
.tag-copy-btn .btn-text {
white-space: nowrap;
}
/* Save button feedback */
#saveSettings.saved { background: #2196F3 !important; }
`);
}
applyGlobalStyles();
// ===== UI COMPONENTS =====
const copyIcon = createCopyIcon();
const checkIcon = createCheckIcon();
// Helper function to create SVG elements
function createSvgElement(tag, attrs = {}) {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.keys(attrs).forEach(key => {
element.setAttribute(key, attrs[key]);
});
return element;
}
// Helper function to create SVG icons with common attributes
function createSvgIcon(children = [], attrs = {}) {
const svg = createSvgElement("svg", {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
...attrs
});
children.forEach(child => svg.appendChild(child));
return svg;
}
function createCopyIcon() {
const rect = createSvgElement("rect", {
x: "9",
y: "9",
width: "13",
height: "13",
rx: "2"
});
const path = createSvgElement("path", {
d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
});
return createSvgIcon([rect, path]);
}
function createCheckIcon() {
const path = createSvgElement("path", {
d: "M20 6L9 17l-5-5"
});
return createSvgIcon([path]);
}
// ===== MEMORY LEAK PREVENTION =====
let currentPreview = null;
let copyButton = null;
let hideTimeout = null;
let isMouseOverPreview = false;
let isMouseOverButton = false;
let currentPreviewLink = null;
// ===== MAIN FUNCTIONALITY =====
function initCopyButton() {
// Create a single global copy button
copyButton = document.createElement("button");
copyButton.className = "tag-copy-btn";
copyButton.title = t("title");
// Clone the copy icon and append it to the button
const iconClone = copyIcon.cloneNode(true);
copyButton.appendChild(iconClone);
// Add click handler
copyButton.addEventListener("click", async (e) => {
e.stopPropagation();
e.preventDefault();
if (!currentPreview) return;
// Get the post ID and tags from the current preview's parent article
const article = currentPreview.closest('article');
if (!article) return;
const tagsString = article.dataset.tags;
if (!tagsString) return;
// Format tags according to settings
let formattedTags = tagsString;
// Apply transformations in correct order
if (SETTINGS.escapeParentheses) {
formattedTags = formattedTags.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
if (SETTINGS.escapeColons) {
formattedTags = formattedTags.replace(/:/g, "\\:");
}
// Split tags by space, process each tag, then join back
if (SETTINGS.replaceUnderscores || SETTINGS.addCommas) {
const tags = formattedTags.split(' ');
const processedTags = tags.map(tag => {
// Replace underscores with spaces within each tag
if (SETTINGS.replaceUnderscores) {
tag = tag.replace(/_/g, ' ');
}
return tag;
});
// Join tags with comma+space if needed, otherwise with space
if (SETTINGS.addCommas) {
formattedTags = processedTags.join(', ');
} else {
formattedTags = processedTags.join(' ');
}
}
try {
await navigator.clipboard.writeText(formattedTags);
showFeedback(copyButton);
} catch (err) {
console.error("Copy error:", err);
}
});
// Add mouse events to the button itself
copyButton.addEventListener('mouseenter', () => {
// Clear any pending hide timeout when mouse enters the button
isMouseOverButton = true;
clearHideTimeout();
});
copyButton.addEventListener('mouseleave', (e) => {
isMouseOverButton = false;
// Only hide if mouse is not over preview
if (!isMouseOverPreview) {
scheduleHideButton();
}
});
}
function showFeedback(btn) {
// Store the original content
const originalContent = btn.cloneNode(true);
// Clear the button and add check icon
btn.innerHTML = '';
const checkClone = checkIcon.cloneNode(true);
btn.appendChild(checkClone);
btn.classList.add("copied");
// Change background to indicate successful copy
const originalBg = btn.style.background;
btn.style.background = "rgba(33, 150, 243, 0.9)"; // Blue color for success
setTimeout(() => {
// Restore original content
btn.innerHTML = '';
const originalIcon = originalContent.firstChild.cloneNode(true);
btn.appendChild(originalIcon);
btn.classList.remove("copied");
// Restore original background
btn.style.background = originalBg || "rgba(0, 0, 0, 0.7)";
}, 2000);
}
function positionButton(previewElement) {
if (!copyButton || !previewElement) return;
// Find the post-preview-link element
const previewLink = previewElement.querySelector('.post-preview-link');
if (!previewLink) return;
// Append button directly to the preview link
previewLink.style.position = 'relative';
previewLink.appendChild(copyButton);
// Position button in the bottom-right corner of the link
Object.assign(copyButton.style, {
position: 'absolute',
bottom: '5px',
right: '5px',
zIndex: '10'
});
}
function showButton(previewElement) {
if (!copyButton) return;
// Clear any pending hide timeout
clearHideTimeout();
currentPreview = previewElement;
positionButton(previewElement);
// Add a small delay before showing to prevent flickering
hideTimeout = setTimeout(() => {
copyButton.classList.add('visible');
}, 50);
}
function scheduleHideButton() {
// Clear any pending hide timeout
clearHideTimeout();
// Schedule hiding with delay
hideTimeout = setTimeout(() => {
if (!isMouseOverPreview && !isMouseOverButton) {
hideButton();
}
}, 200);
}
function clearHideTimeout() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
function hideButton() {
if (!copyButton) return;
clearHideTimeout();
copyButton.classList.remove('visible');
currentPreview = null;
}
function attachHoverHandlers() {
// Attach hover handlers to all post previews
const postPreviews = document.querySelectorAll('article > .post-preview-container');
postPreviews.forEach(preview => {
preview.addEventListener('mouseenter', () => {
isMouseOverPreview = true;
showButton(preview);
});
preview.addEventListener('mouseleave', (e) => {
isMouseOverPreview = false;
// Only schedule hide if mouse is not over button
if (!isMouseOverButton) {
scheduleHideButton();
}
});
});
}
function addCopyButton() {
// Attach hover handlers to existing previews
attachHoverHandlers();
}
// ===== SETTINGS EDITOR =====
// Helper function to create DOM elements with properties and styles
function createElement(tag, props = {}, styles = {}) {
const element = document.createElement(tag);
// Set properties/attributes
Object.keys(props).forEach(key => {
if (key === 'textContent' || key === 'innerHTML') {
element[key] = props[key];
} else {
element.setAttribute(key, props[key]);
}
});
// Set styles
Object.keys(styles).forEach(key => {
element.style[key] = styles[key];
});
return element;
}
// Helper function to create checkbox setting
function createCheckboxSetting(id, labelKey, isChecked, isDarkMode) {
const container = createElement("div", {}, {
display: "flex",
alignItems: "center",
padding: "8px",
border: `1px solid ${isDarkMode ? "var(--default-border-color)" : "#ddd"}`,
borderRadius: "3px",
background: isDarkMode ? "var(--grey-7)" : "#f9f9f9",
cursor: "pointer",
marginBottom: "4px"
});
const input = createElement("input", {
type: "checkbox",
id: id
}, {
marginRight: "6px",
transform: "scale(1.1)"
});
if (isChecked) {
input.checked = true;
}
container.appendChild(input);
const label = createElement("label", {
textContent: t(labelKey),
htmlFor: id
}, {
color: isDarkMode ? "var(--text-color)" : "#333",
cursor: "pointer",
flex: "1"
});
container.appendChild(label);
// Add click handler to container
container.addEventListener("click", (e) => {
// Don't trigger if clicking on the checkbox itself
if (e.target !== input) {
input.checked = !input.checked;
}
});
return { container, input, label };
}
// Helper function to update button text with temporary feedback
function updateButtonTextWithFeedback(button, defaultText, feedbackText, className, duration = 3000) {
button.textContent = feedbackText;
if (className) button.classList.add(className);
setTimeout(() => {
button.textContent = defaultText;
if (className) button.classList.remove(className);
}, duration);
}
function createSettingsEditor() {
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Create editor content using helper function
const editor = createElement("div", { id: "tag-copier-editor" });
const editorContainer = createElement("div", {}, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: "5px 10px 10px 10px",
border: `1px solid ${isDarkMode ? "var(--default-border-color)" : "#ddd"}`,
borderRadius: "4px",
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.1), 0 1px 4px rgba(0, 0, 0, 0.08)",
zIndex: "9999",
fontFamily: "Tahoma, Verdana, Helvetica, sans-serif",
width: "300px",
maxWidth: "90vw",
maxHeight: "90vh",
overflowY: "auto",
background: isDarkMode ? "var(--post-tooltip-background-color)" : "white",
userSelect: "none"
});
const heading = createElement("h2", { textContent: t("settings") }, {
margin: "0 0 12px 0",
textAlign: "center",
color: isDarkMode ? "var(--header-color)" : "#000"
});
editorContainer.appendChild(heading);
// Create settings container
const settingsContainer = createElement("div", {}, {
display: "flex",
flexDirection: "column",
gap: "10px",
marginBottom: "20px"
});
// Add commas checkbox
const { container: addCommasContainer, input: addCommasInput } = createCheckboxSetting(
"addCommas",
"addCommas",
SETTINGS.addCommas,
isDarkMode
);
settingsContainer.appendChild(addCommasContainer);
// Escape parentheses checkbox
const { container: escapeParenthesesContainer, input: escapeParenthesesInput } = createCheckboxSetting(
"escapeParentheses",
"escapeParentheses",
SETTINGS.escapeParentheses,
isDarkMode
);
settingsContainer.appendChild(escapeParenthesesContainer);
// Escape colons checkbox
const { container: escapeColonsContainer, input: escapeColonsInput } = createCheckboxSetting(
"escapeColons",
"escapeColons",
SETTINGS.escapeColons,
isDarkMode
);
settingsContainer.appendChild(escapeColonsContainer);
// Replace underscores checkbox
const { container: replaceUnderscoresContainer, input: replaceUnderscoresInput } = createCheckboxSetting(
"replaceUnderscores",
"replaceUnderscores",
SETTINGS.replaceUnderscores,
isDarkMode
);
settingsContainer.appendChild(replaceUnderscoresContainer);
// Language settings
const languageContainer = createElement("div", {}, {
display: "flex",
alignItems: "center",
padding: "8px",
border: `1px solid ${isDarkMode ? "var(--default-border-color)" : "#ddd"}`,
borderRadius: "3px",
background: isDarkMode ? "var(--grey-7)" : "#f9f9f9",
marginBottom: "0",
cursor: "default"
});
const languageLabel = createElement("label", {
textContent: t("language"),
htmlFor: "language"
}, {
color: isDarkMode ? "var(--text-color)" : "#333",
marginRight: "10px",
cursor: "default"
});
languageContainer.appendChild(languageLabel);
const languageSelect = createElement("select", { id: "language" }, {
background: isDarkMode ? "var(--grey-6)" : "#ffffff",
color: isDarkMode ? "#fff" : "#333",
padding: "6px",
borderRadius: "3px",
border: `2px solid ${isDarkMode ? "#4a90e2" : "#007bff"}`,
flex: "1",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)"
});
const autoOption = createElement("option", {
value: "auto",
textContent: t("langAuto")
});
if (SETTINGS.language === "auto") {
autoOption.selected = true;
}
languageSelect.appendChild(autoOption);
const enOption = createElement("option", {
value: "en",
textContent: t("langEn")
});
if (SETTINGS.language === "en") {
enOption.selected = true;
}
languageSelect.appendChild(enOption);
const ruOption = createElement("option", {
value: "ru",
textContent: t("langRu")
});
if (SETTINGS.language === "ru") {
ruOption.selected = true;
}
languageSelect.appendChild(ruOption);
languageContainer.appendChild(languageSelect);
settingsContainer.appendChild(languageContainer);
editorContainer.appendChild(settingsContainer);
// Buttons container
const buttonsContainer = createElement("div", {}, {
display: "flex",
justifyContent: "end"
});
const saveButton = createElement("button", {
id: "saveSettings",
textContent: t("saveButton")
}, {
padding: "6px 12px",
background: "#4CAF50",
color: "white",
border: "none",
borderRadius: "2px",
cursor: "pointer",
fontWeight: "bold"
});
buttonsContainer.appendChild(saveButton);
const closeButton = createElement("button", {
id: "closeEditor",
textContent: t("cancelButton")
}, {
marginLeft: "10px",
padding: "6px 12px",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "2px",
cursor: "pointer"
});
buttonsContainer.appendChild(closeButton);
editorContainer.appendChild(buttonsContainer);
editor.appendChild(editorContainer);
// Add event handlers
closeButton.addEventListener("click", () => {
document.body.removeChild(editor);
});
const getInputValue = (id) => {
const el = document.getElementById(id);
if (!el) return null;
if (el.type === "checkbox") return el.checked;
if (el.type === "range" || el.type === "number") {
return parseFloat(el.value);
}
return el.value;
};
const saveSettings = () => {
const newSettings = {
addCommas: getInputValue("addCommas"),
escapeParentheses: getInputValue("escapeParentheses"),
escapeColons: getInputValue("escapeColons"),
replaceUnderscores: getInputValue("replaceUnderscores"),
language: getInputValue("language"),
};
// Update global settings
Object.assign(SETTINGS, newSettings);
GM_setValue("tagCopierSettings", SETTINGS);
// Apply changes
document.querySelectorAll(".tag-copy-btn").forEach((btn) => {
btn.title = t("title");
// Update button content (only icon)
btn.innerHTML = '';
const iconClone = copyIcon.cloneNode(true);
btn.appendChild(iconClone);
});
// Update button text with feedback
updateButtonTextWithFeedback(saveButton, t("saveButton"), t("savedButton"), "saved", 3000);
};
saveButton.addEventListener("click", saveSettings);
return editor;
}
function openSettingsEditor() {
const existingEditor = document.getElementById("tag-copier-editor");
if (existingEditor) return;
const editor = createSettingsEditor();
document.body.appendChild(editor);
}
// ===== INITIALIZATION =====
GM_registerMenuCommand(t("settings"), openSettingsEditor);
// Global variables for observer and event handlers
let observer = null;
let throttleTimer = null;
// Optimized observer with throttling
function initObserver() {
if (observer) {
observer.disconnect();
}
observer = new MutationObserver(() => {
// Throttling to reduce frequency of calls
if (throttleTimer) return;
throttleTimer = setTimeout(() => {
addCopyButton();
throttleTimer = null;
}, 100); // Ограничиваем частоту вызовов до 10 раз в секунду
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
// Initialize with optimized observer
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initCopyButton(); // Initialize the single copy button
initObserver();
addCopyButton();
});
} else {
initCopyButton(); // Initialize the single copy button
initObserver();
addCopyButton();
}
// Handle PJAX navigation (used by Danbooru)
document.addEventListener('pjax:end', function() {
// Reattach hover handlers to new previews
attachHoverHandlers();
});
// Cleanup function for potential script re-initialization
window.addEventListener('beforeunload', () => {
if (observer) {
observer.disconnect();
observer = null;
}
if (throttleTimer) {
clearTimeout(throttleTimer);
throttleTimer = null;
}
// Remove the copy button from the DOM
if (copyButton && copyButton.parentNode) {
copyButton.parentNode.removeChild(copyButton);
}
});
})()