Article Copy Button

Add copy buttons to some sites. CONFIGURABLE!

  1. // ==UserScript==
  2. // @name Article Copy Button
  3. // @namespace https://github.com/BobbyWibowo
  4. // @version 1.3.2
  5. // @description Add copy buttons to some sites. CONFIGURABLE!
  6. // @author Bobby Wibowo
  7. // @license MIT
  8. // @match *://*/*
  9. // @run-at document-end
  10. // @grant GM_addStyle
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @require https://cdn.jsdelivr.net/npm/sentinel-js@0.0.7/dist/sentinel.min.js
  14. // ==/UserScript==
  15.  
  16. /* global sentinel */
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. const _LOG_TIME_FORMAT = new Intl.DateTimeFormat('en-GB', {
  22. hour: '2-digit',
  23. minute: '2-digit',
  24. second: '2-digit',
  25. fractionalSecondDigits: 3
  26. });
  27.  
  28. const log = (message, ...args) => {
  29. const prefix = `[${_LOG_TIME_FORMAT.format(Date.now())}]: `;
  30. if (typeof message === 'string') {
  31. return console.log(prefix + message, ...args);
  32. } else {
  33. return console.log(prefix, message, ...args);
  34. }
  35. };
  36.  
  37. /** CONFIG **/
  38.  
  39. /* It's recommended to edit these values through your userscript manager's storage/values editor.
  40. * Visit YouTube once after installing the script to allow it to populate its storage with default values.
  41. * Especially necessary for Tampermonkey to show the script's Storage tab when Advanced mode is turned on.
  42. */
  43. const ENV_DEFAULTS = {
  44. MODE: 'PROD',
  45.  
  46. ARTICLES_CONFIG: []
  47. };
  48.  
  49. /* Hard-coded preset values.
  50. * Specifying custom values will extend instead of replacing them.
  51. */
  52. const PRESETS = {
  53. ARTICLES_CONFIG: [
  54. {
  55. whitelistedHosts: [
  56. 'quanben-xiaoshuo.com'
  57. ],
  58. parentSelector: '.wrapper .box',
  59. titleSelector: '.title',
  60. articleSelector: '#articlebody',
  61. 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'
  62. },
  63. {
  64. parentSelector: '.wp-manga-page',
  65. titleSelector: '.breadcrumb .active',
  66. articleSelector: '.reading-content div[class*="text-"]',
  67. AIPromptPrefix: 'Improve readability of this web novel chapter, without any loss:\n\n'
  68. },
  69. {
  70. parentSelector: '#chapter',
  71. titleSelector: '.chapter-title',
  72. articleSelector: '.chapter-c'
  73. },
  74. {
  75. whitelistedHosts: [
  76. 'www.hoyolab.com'
  77. ],
  78. sentinel: true, // this option is ignored without whitelistedHosts for performance
  79. parentSelector: '.mhy-article-page',
  80. titleSelector: '.mhy-article-page__title h1',
  81. articleSelector: '.mhy-article-page__content'
  82. },
  83. {
  84. whitelistedHosts: [
  85. 'www.reddit.com'
  86. ],
  87. sentinel: true,
  88. parentSelector: 'shreddit-post',
  89. titleSelector: '[id^="post-title-"]',
  90. articleSelector: 'div[slot="text-body"], div[slot="expando-content"]'
  91. },
  92. {
  93. whitelistedHosts: [
  94. 'old.reddit.com'
  95. ],
  96. sentinel: true,
  97. parentSelector: 'div[id^="thing_"]',
  98. titleSelector: 'a.title',
  99. articleSelector: '.expando:not(.expando-uninitialized)'
  100. }
  101. ]
  102. };
  103.  
  104. const ENV = {};
  105.  
  106. // Store default values.
  107. for (const key of Object.keys(ENV_DEFAULTS)) {
  108. const stored = GM_getValue(key);
  109. if (stored === null || stored === undefined) {
  110. ENV[key] = ENV_DEFAULTS[key];
  111. GM_setValue(key, ENV_DEFAULTS[key]);
  112. } else {
  113. ENV[key] = stored;
  114. }
  115. }
  116.  
  117. const _DOCUMENT_FRAGMENT = document.createDocumentFragment();
  118. const queryCheck = selector => _DOCUMENT_FRAGMENT.querySelector(selector);
  119.  
  120. const isSelectorValid = selector => {
  121. try {
  122. queryCheck(selector);
  123. } catch {
  124. return false;
  125. }
  126. return true;
  127. };
  128.  
  129. const CONFIG = {};
  130.  
  131. // Extend hard-coded preset values with user-defined custom values, if applicable.
  132. for (const key of Object.keys(ENV)) {
  133. if (Array.isArray(PRESETS[key])) {
  134. CONFIG[key] = PRESETS[key];
  135. if (ENV[key]) {
  136. const customValues = Array.isArray(ENV[key]) ? ENV[key] : ENV[key].split(',').map(s => s.trim());
  137. CONFIG[key].push(...customValues);
  138. }
  139. } else {
  140. CONFIG[key] = PRESETS[key] || null;
  141. if (ENV[key] !== null) {
  142. CONFIG[key] = ENV[key];
  143. }
  144. }
  145. }
  146.  
  147. let logDebug = () => {};
  148. if (CONFIG.MODE !== 'PROD') {
  149. logDebug = log;
  150. for (const key of Object.keys(CONFIG)) {
  151. logDebug(`${key} =`, CONFIG[key]);
  152. }
  153. }
  154.  
  155. /** STYLES **/
  156.  
  157. const GLOBAL_STYLE = /*css*/`
  158. .copy-article-button-container {
  159. display: flex;
  160. justify-content: center;
  161. gap: 4px;
  162. margin-bottom: 16px;
  163. }
  164.  
  165. .copy-article-button {
  166. --button-bg: #e5e6eb;
  167. --button-hover-bg: #d7dbe2;
  168. --button-text-color: #4e5969;
  169. --button-hover-text-color: #164de5;
  170. --button-border-radius: 6px;
  171. --button-diameter: 24px;
  172. --button-outline-width: 2px;
  173. --button-outline-color: #9f9f9f;
  174. --tooltip-bg: #1d2129;
  175. --toolptip-border-radius: 4px;
  176. --tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace;
  177. --tooltip-font-size: 12px;
  178. --tootip-text-color: #fff;
  179. --tooltip-padding-x: 7px;
  180. --tooltip-padding-y: 7px;
  181. --tooltip-offset: 8px;
  182. /*--tooltip-transition-duration: 0.3s;*/
  183. }
  184.  
  185. html[data-darkreader-scheme="dark"] .copy-article-button,
  186. body[class*="text-ui-light"] .copy-article-button,
  187. main[class*="dark-background"] .copy-article-button {
  188. --button-bg: #353434;
  189. --button-hover-bg: #464646;
  190. --button-text-color: #ccc;
  191. --button-outline-color: #999;
  192. --button-hover-text-color: #8bb9fe;
  193. --tooltip-bg: #f4f3f3;
  194. --tootip-text-color: #111;
  195. }
  196.  
  197. .copy-article-button {
  198. box-sizing: border-box;
  199. width: var(--button-diameter);
  200. height: var(--button-diameter);
  201. border-radius: var(--button-border-radius);
  202. background-color: var(--button-bg);
  203. color: var(--button-text-color);
  204. border: none;
  205. cursor: pointer;
  206. position: relative;
  207. outline: var(--button-outline-width) solid transparent;
  208. transition: all 0.2s ease;
  209. }
  210.  
  211. .tooltip {
  212. position: absolute;
  213. opacity: 0;
  214. left: calc(100% + var(--tooltip-offset));
  215. top: 50%;
  216. transform: translateY(-50%);
  217. white-space: nowrap;
  218. font: var(--tooltip-font-size) var(--tooltip-font-family);
  219. color: var(--tootip-text-color);
  220. background: var(--tooltip-bg);
  221. padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
  222. border-radius: var(--toolptip-border-radius);
  223. pointer-events: none;
  224. transition: all var(--tooltip-transition-duration) cubic-bezier(0.68, -0.55, 0.265, 1.55);
  225. z-index: 1;
  226. }
  227.  
  228. .tooltip::before {
  229. content: attr(data-text-initial);
  230. }
  231.  
  232. .tooltip::after {
  233. content: "";
  234. width: var(--tooltip-padding-y);
  235. height: var(--tooltip-padding-y);
  236. background: inherit;
  237. position: absolute;
  238. top: 50%;
  239. left: calc(var(--tooltip-padding-y) / 2 * -1);
  240. transform: translateY(-50%) rotate(45deg);
  241. z-index: -999;
  242. pointer-events: none;
  243. }
  244.  
  245. .copy-article-button svg {
  246. position: absolute;
  247. top: 50%;
  248. left: 50%;
  249. transform: translate(-50%, -50%);
  250. }
  251.  
  252. .checkmark,
  253. .failedmark {
  254. display: none;
  255. }
  256.  
  257. .copy-article-button:hover .tooltip,
  258. .copy-article-button:focus:not(:focus-visible) .tooltip {
  259. opacity: 1;
  260. visibility: visible;
  261. }
  262.  
  263. .copy-article-button:focus:not(:focus-visible) .tooltip::before {
  264. content: attr(data-text-end);
  265. }
  266. .copy-article-button.copy-failed:focus:not(:focus-visible) .tooltip::before {
  267. content: attr(data-text-failed);
  268. }
  269.  
  270. .copy-article-button:focus:not(:focus-visible) .clipboard {
  271. display: none;
  272. }
  273.  
  274. .copy-article-button:focus:not(:focus-visible) .checkmark {
  275. display: block;
  276. }
  277.  
  278. .copy-article-button.copy-failed:focus:not(:focus-visible) .checkmark {
  279. display: none;
  280. }
  281.  
  282. .copy-article-button.copy-failed:focus:not(:focus-visible) .failedmark {
  283. display: block;
  284. }
  285.  
  286. .copy-article-button:hover,
  287. .copy-article-button:focus {
  288. background-color: var(--button-hover-bg);
  289. }
  290.  
  291. .copy-article-button:active {
  292. outline: var(--button-outline-width) solid var(--button-outline-color);
  293. }
  294.  
  295. .copy-article-button:hover svg {
  296. color: var(--button-hover-text-color);
  297. }
  298. `;
  299.  
  300. let globalStyleAdded = false;
  301.  
  302. const formatArticle = options => {
  303. if (typeof options !== 'object' || !(options.article instanceof Node)) {
  304. return false;
  305. }
  306.  
  307. let formatted = options.article.innerText.trim();
  308.  
  309. if (options.title instanceof Node) {
  310. const title = options.title.innerText.trim();
  311. if (title) {
  312. formatted = `${title}\n\n${formatted}`;
  313. }
  314. }
  315.  
  316. if (options.AIPromptPrefix) {
  317. formatted = options.AIPromptPrefix + formatted;
  318. }
  319.  
  320. return formatted;
  321. };
  322.  
  323. const handleCopyError = (event, error = new Error()) => {
  324. log('Could not copy: ', error);
  325. event.element.classList.add('copy-failed');
  326. };
  327.  
  328. const handleArticleCopyClick = async (event, options = {}) => {
  329. event.stopPropagation();
  330. try {
  331. const text = formatArticle(options);
  332. await navigator.clipboard.writeText(text);
  333. log(`Article copied to clipboard.\n\n ${text}`);
  334. } catch (error) {
  335. error._options = options;
  336. handleCopyError(event, error);
  337. }
  338. };
  339.  
  340. const BUTTON_INNER_TEMPLATE = /*html*/`
  341. <span data-text-initial="Copy to clipboard" data-text-end="Copied" data-text-failed="Copy failed, open the console for details!" class="tooltip"></span>
  342. <span>
  343. <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 6.35 6.35" y="0" x="0"
  344. height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
  345. xmlns="http://www.w3.org/2000/svg" class="clipboard">
  346. <g>
  347. <path fill="currentColor"
  348. 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">
  349. </path>
  350. </g>
  351. </svg>
  352. <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 24 24" y="0" x="0" height="14"
  353. width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg"
  354. class="checkmark">
  355. <g>
  356. <path data-original="#000000" fill="currentColor"
  357. 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">
  358. </path>
  359. </g>
  360. </svg>
  361. <svg class="failedmark" xmlns="http://www.w3.org/2000/svg" height="14" width="14" viewBox="0 0 512 512">
  362. <path fill="#FF473E"
  363. 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" />
  364. </svg>
  365. </span>
  366. `;
  367.  
  368. const generateCopyButton = (title, article, AIPromptPrefix = null) => {
  369. const element = document.createElement('button');
  370. element.className = 'copy-article-button';
  371. element.innerHTML = BUTTON_INNER_TEMPLATE;
  372.  
  373. if (AIPromptPrefix) {
  374. element.querySelector(':first-child').dataset.textInitial += ' (with AI prompt prefix)';
  375. }
  376.  
  377. const options = { title, article, AIPromptPrefix };
  378. element.addEventListener('click', event => handleArticleCopyClick(event, options));
  379.  
  380. return element;
  381. };
  382.  
  383. const findArticles = (config, source = document.body) => {
  384. let parents = [];
  385.  
  386. if (source.matches(config.parentSelector)) {
  387. parents = [source];
  388. } else {
  389. // Loop through query results of parents, to support multiple articles at once.
  390. parents = source.querySelectorAll(config.parentSelector);
  391.  
  392. // Also try to look up.
  393. if (!parents.length) {
  394. const up = source.closest(config.parentSelector);
  395. if (up) {
  396. parents = [up];
  397. }
  398. }
  399. }
  400.  
  401. if (!parents.length) {
  402. return null;
  403. }
  404.  
  405. logDebug(`Found ${parents.length} element(s) matching parent selector: ${config.parentSelector}`);
  406.  
  407. let _done = 0;
  408. for (const parent of parents) {
  409. const article = parent.querySelector(config.articleSelector);
  410. if (!article) {
  411. continue;
  412. }
  413.  
  414. // Skip if already processed.
  415. if (article.querySelector('.copy-article-button-container')) {
  416. continue;
  417. }
  418.  
  419. logDebug(`Found element matching article selector: ${config.articleSelector}`);
  420.  
  421. if (!globalStyleAdded) {
  422. GM_addStyle(GLOBAL_STYLE);
  423. globalStyleAdded = true;
  424. }
  425.  
  426. const title = parent.querySelector(config.titleSelector);
  427.  
  428. const copyButtonContainer = document.createElement('div');
  429. copyButtonContainer.className = 'copy-article-button-container';
  430.  
  431. copyButtonContainer.appendChild(generateCopyButton(title, article));
  432. if (config.AIPromptPrefix) {
  433. copyButtonContainer.appendChild(generateCopyButton(title, article, config.AIPromptPrefix));
  434. }
  435.  
  436. article.insertAdjacentElement('afterbegin', copyButtonContainer);
  437. _done++;
  438. }
  439.  
  440. return _done;
  441. };
  442.  
  443. let _rootDone = 0;
  444.  
  445. for (const config of CONFIG.ARTICLES_CONFIG) {
  446. let incomplete = false;
  447.  
  448. ['parentSelector', 'articleSelector'].forEach(key => {
  449. if (!config[key]) {
  450. console.error(`Missing ${key} = `, config);
  451. incomplete = true;
  452. } else if (!isSelectorValid(config[key])) {
  453. console.error(`${key} contains invalid selector = `, config[key]);
  454. }
  455. });
  456.  
  457. if (incomplete) {
  458. continue;
  459. }
  460.  
  461. if (config.titleSelector && !isSelectorValid(config.titleSelector)) {
  462. log('titleSelector contains invalid selector (ignored) = ', config.titleSelector);
  463. }
  464.  
  465. let initSentinel = false;
  466.  
  467. // Skip config if it's whitelisted for specific hosts yet it doesn't match current host.
  468. if (Array.isArray(config.whitelistedHosts)) {
  469. let hostPassed = false;
  470. for (const host of config.whitelistedHosts) {
  471. if (host instanceof RegExp) {
  472. if (host.test(window.location.hostname)) {
  473. hostPassed = true;
  474. }
  475. } else if (host === window.location.hostname) {
  476. hostPassed = true;
  477. }
  478. }
  479.  
  480. if (!hostPassed) {
  481. continue;
  482. }
  483.  
  484. log(`Host whitelisted for parent selector: ${config.parentSelector}`);
  485.  
  486. if (config.sentinel) {
  487. initSentinel = true;
  488. }
  489. }
  490.  
  491. if (!initSentinel && config.sentinel) {
  492. log('Sentinel can only be used for config with whitelisted hosts.');
  493. }
  494.  
  495. if (initSentinel) {
  496. sentinel.on([
  497. config.parentSelector,
  498. config.articleSelector
  499. ], element => {
  500. findArticles(config, element);
  501. });
  502. } else {
  503. const done = findArticles(config);
  504. if (done !== null) {
  505. _rootDone += done;
  506. }
  507. }
  508. }
  509.  
  510. if (_rootDone > 0) {
  511. log(`Added ${_rootDone} copy button(s).`);
  512. }
  513. })();