Article Copy Button

09/02/2025, 16.57.34

目前為 2025-02-16 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Article Copy Button
// @namespace   https://github.com/BobbyWibowo
// @include     http://*
// @include     https://*
// @grant       GM_addStyle
// @version     1.0.4
// @author      Bobby Wibowo
// @license     MIT
// @run-at      document-end
// @description 09/02/2025, 16.57.34
// ==/UserScript==

(function () {
  'use strict'

  const ARTICLES_CONFIG = [
    {
      whitelistedHosts: [
        'quanben-xiaoshuo.com'
      ],
      parentSelector: '.wrapper .box',
      titleSelector: '.title',
      articleSelector: '#articlebody',
      AIPromptPrefix: 'Display the translation of this web novel chapter, retaining all original text without any loss in translation, and incorporating the remembered glossary:\n\n'
    },
    {
      parentSelector: '.main-col',
      titleSelector: '.breadcrumb .active',
      articleSelector: '.reading-content'
    },
    {
      parentSelector: '#chapter',
      titleSelector: '.chapter-title',
      articleSelector: '.chapter-c'
    }
  ];

  const GLOBAL_STYLE = /*css*/`
  .copy-article-button-container {
    display: flex;
    justify-content: center;
    gap: 4px;
    margin-bottom: 16px;
  }

  .copy-article-button {
    --button-bg: #e5e6eb;
    --button-hover-bg: #d7dbe2;
    --button-text-color: #4e5969;
    --button-hover-text-color: #164de5;
    --button-border-radius: 6px;
    --button-diameter: 24px;
    --button-outline-width: 2px;
    --button-outline-color: #9f9f9f;
    --tooltip-bg: #1d2129;
    --toolptip-border-radius: 4px;
    --tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace;
    --tooltip-font-size: 12px;
    --tootip-text-color: #fff;
    --tooltip-padding-x: 7px;
    --tooltip-padding-y: 7px;
    --tooltip-offset: 8px;
    /* --tooltip-transition-duration: 0.3s; */
  }

  html[data-darkreader-scheme="dark"] .copy-article-button,
  body[class*="text-ui-light"] .copy-article-button,
  main[class*="dark-background"] .copy-article-button {
    --button-bg: #353434;
    --button-hover-bg: #464646;
    --button-text-color: #ccc;
    --button-outline-color: #999;
    --button-hover-text-color: #8bb9fe;
    --tooltip-bg: #f4f3f3;
    --tootip-text-color: #111;
  }

  .copy-article-button {
    box-sizing: border-box;
    width: var(--button-diameter);
    height: var(--button-diameter);
    border-radius: var(--button-border-radius);
    background-color: var(--button-bg);
    color: var(--button-text-color);
    border: none;
    cursor: pointer;
    position: relative;
    outline: var(--button-outline-width) solid transparent;
    transition: all 0.2s ease;
  }

  .tooltip {
    position: absolute;
    opacity: 0;
    left: calc(100% + var(--tooltip-offset));
    top: 50%;
    transform: translateY(-50%);
    white-space: nowrap;
    font: var(--tooltip-font-size) var(--tooltip-font-family);
    color: var(--tootip-text-color);
    background: var(--tooltip-bg);
    padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
    border-radius: var(--toolptip-border-radius);
    pointer-events: none;
    transition: all var(--tooltip-transition-duration) cubic-bezier(0.68, -0.55, 0.265, 1.55);
    z-index: 1;
  }

  .tooltip::before {
    content: attr(data-text-initial);
  }

  .tooltip::after {
    content: "";
    width: var(--tooltip-padding-y);
    height: var(--tooltip-padding-y);
    background: inherit;
    position: absolute;
    top: 50%;
    left: calc(var(--tooltip-padding-y) / 2 * -1);
    transform: translateY(-50%) rotate(45deg);
    z-index: -999;
    pointer-events: none;
  }

  .copy-article-button svg {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .checkmark,
  .failedmark {
    display: none;
  }

  .copy-article-button:hover .tooltip,
  .copy-article-button:focus:not(:focus-visible) .tooltip {
    opacity: 1;
    visibility: visible;
  }

  .copy-article-button:focus:not(:focus-visible) .tooltip::before {
    content: attr(data-text-end);
  }
  .copy-article-button.copy-failed:focus:not(:focus-visible) .tooltip::before {
    content: attr(data-text-failed);
  }

  .copy-article-button:focus:not(:focus-visible) .clipboard {
    display: none;
  }

  .copy-article-button:focus:not(:focus-visible) .checkmark {
    display: block;
  }

  .copy-article-button.copy-failed:focus:not(:focus-visible) .checkmark {
    display: none;
  }

  .copy-article-button.copy-failed:focus:not(:focus-visible) .failedmark {
    display: block;
  }

  .copy-article-button:hover,
  .copy-article-button:focus {
    background-color: var(--button-hover-bg);
  }

  .copy-article-button:active {
    outline: var(--button-outline-width) solid var(--button-outline-color);
  }

  .copy-article-button:hover svg {
    color: var(--button-hover-text-color);
  }
  `;

  const log = (message, ...args) => {
    console.log(`[Article Copy Button]: ${message}`, ...args)
  }

  const logError = (message, ...args) => {
    console.error(`[Article Copy Button]: ${message}`, ...args)
  }

  let globalStyleAdded = false;

  function formatArticle({ title, article, AIPromptPrefix = '' }) {
    const header = title ? title.innerText.trim() : null;
    const body = article.innerText.trim();

    const formatted = (header ? `${header}\n\n` : '') + body;

    return AIPromptPrefix + formatted;
  }

  function handleCopyError({ event, title, article, error = new Error() }) {
    logError('Could not copy: ', error);
    event.element.classList.add('copy-failed');
  }

  function copyTextToClipboard({ event, title, article, text }) {
    navigator.clipboard.writeText(text).then(function () {
      log('Article copied to clipboard.\n\n' + text);
    }).catch(function (error) {
      handleCopyError({ event, title, article, error })
    });
  }

  function handleArticleCopyClick({ event, title, article, AIPromptPrefix }) {
    event.stopPropagation();
    try {
      const text = formatArticle({ title, article, AIPromptPrefix });
      copyTextToClipboard({ event, title, article, text });
    } catch (error) {
      handleCopyError({ event, title, article, error })
    }
  }

  const BUTTON_INNER_TEMPLATE = /*html*/`
    <span data-text-initial="Copy to clipboard" data-text-end="Copied" data-text-failed="Copy failed, open the console for details!" class="tooltip"></span>
    <span>
      <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 6.35 6.35" y="0" x="0"
        height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
        xmlns="http://www.w3.org/2000/svg" class="clipboard">
        <g>
          <path fill="currentColor"
            d="M2.43.265c-.3 0-.548.236-.573.53h-.328a.74.74 0 0 0-.735.734v3.822a.74.74 0 0 0 .735.734H4.82a.74.74 0 0 0 .735-.734V1.529a.74.74 0 0 0-.735-.735h-.328a.58.58 0 0 0-.573-.53zm0 .529h1.49c.032 0 .049.017.049.049v.431c0 .032-.017.049-.049.049H2.43c-.032 0-.05-.017-.05-.049V.843c0-.032.018-.05.05-.05zm-.901.53h.328c.026.292.274.528.573.528h1.49a.58.58 0 0 0 .573-.529h.328a.2.2 0 0 1 .206.206v3.822a.2.2 0 0 1-.206.205H1.53a.2.2 0 0 1-.206-.205V1.529a.2.2 0 0 1 .206-.206z">
          </path>
        </g>
      </svg>
      <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 24 24" y="0" x="0" height="14"
        width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg"
        class="checkmark">
        <g>
          <path data-original="#000000" fill="currentColor"
            d="M9.707 19.121a.997.997 0 0 1-1.414 0l-5.646-5.647a1.5 1.5 0 0 1 0-2.121l.707-.707a1.5 1.5 0 0 1 2.121 0L9 14.171l9.525-9.525a1.5 1.5 0 0 1 2.121 0l.707.707a1.5 1.5 0 0 1 0 2.121z">
          </path>
        </g>
      </svg>
      <svg class="failedmark" xmlns="http://www.w3.org/2000/svg" height="14" width="14" viewBox="0 0 512 512">
        <path fill="#FF473E"
          d="m330.443 256l136.765-136.765c14.058-14.058 14.058-36.85 0-50.908l-23.535-23.535c-14.058-14.058-36.85-14.058-50.908 0L256 181.557L119.235 44.792c-14.058-14.058-36.85-14.058-50.908 0L44.792 68.327c-14.058 14.058-14.058 36.85 0 50.908L181.557 256L44.792 392.765c-14.058 14.058-14.058 36.85 0 50.908l23.535 23.535c14.058 14.058 36.85 14.058 50.908 0L256 330.443l136.765 136.765c14.058 14.058 36.85 14.058 50.908 0l23.535-23.535c14.058-14.058 14.058-36.85 0-50.908z" />
      </svg>
    </span>
  `;

  let i = 0;

  for (const config of ARTICLES_CONFIG) {
    // Skip config if it's whitelisted for specific hosts yet it doesn't match current host
    if (Array.isArray(config.whitelistedHosts)) {
      let hostPassed = false;
      for (const host of config.whitelistedHosts) {
        let _pass = true;
        if (host instanceof RegExp) {
          if (host.test(window.location.hostname)) {
            hostPassed = true;
          }
        } else if (host === window.location.hostname) {
          hostPassed = true;
        }
      }
      if (!hostPassed) {
        continue;
      }
    }

    // Loop through query results of parents, to potentially support multiple articles at once
    // TODO Mutation observer for dynamic content?
    const parents = document.querySelectorAll(config.parentSelector);
    for (const parent of parents) {
      const article = parent.querySelector(config.articleSelector);
      if (!article) {
        continue;
      }

      const title = parent.querySelector(config.titleSelector);

      const copyButtonContainer = document.createElement('div');
      copyButtonContainer.className = 'copy-article-button-container';

      const copyButton = document.createElement('button');
      copyButton.className = 'copy-article-button';
      copyButton.innerHTML = BUTTON_INNER_TEMPLATE;
      copyButton.addEventListener('click', event => handleArticleCopyClick({ event, title, article }));
      copyButtonContainer.appendChild(copyButton);
      i++;

      if (config.AIPromptPrefix) {
        const copyButtonAI = document.createElement('button');
        copyButtonAI.className = 'copy-article-button';
        copyButtonAI.innerHTML = BUTTON_INNER_TEMPLATE;
        copyButtonAI.querySelector(':first-child').dataset.textInitial += ' (with AI prompt prefix)';
        copyButtonAI.addEventListener('click', event => handleArticleCopyClick({ event, title, article, AIPromptPrefix: config.AIPromptPrefix }));
        copyButtonContainer.appendChild(copyButtonAI);
        i++;
      }

      if (!globalStyleAdded) {
        GM_addStyle(GLOBAL_STYLE);
        globalStyleAdded = true;
      }

      article.insertAdjacentElement('afterbegin', copyButtonContainer);
    }
  }

  if (i > 0) {
    log(`Added ${i} copy button(s).`);
  }
})()