您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a 'Copy all tags' button to the thumbnail hover preview tooltip. Copy all of a tooltip tags instantly!
// ==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); } }); })()