Booru Tags Hauler

Adds a 'Copy all tags' button to the thumbnail hover preview tooltip. Copy all of a tooltip tags instantly!

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);
    }
  });
})()