GitHub Gist Copier

Add copy button to Gist files for easy code copying.

当前为 2025-05-09 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Gist Copier
  3. // @description Add copy button to Gist files for easy code copying.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.2
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @run-at document-end
  11. // @match https://gist.github.com/*
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_setClipboard
  14. // @grant GM_addStyle
  15. // @connect api.codetabs.com
  16. // @connect gist.githubusercontent.com
  17. // ==/UserScript==
  18.  
  19. (function() {
  20. 'use strict';
  21.  
  22. GM_addStyle(`
  23. @keyframes spin {
  24. 0% { transform: rotate(0deg); }
  25. 100% { transform: rotate(360deg); }
  26. }
  27. .gist-copy-spinner {
  28. animation: spin 0.75s linear infinite;
  29. transform-origin: center;
  30. }
  31. `);
  32.  
  33. function noop() { }
  34.  
  35. function debounce(f, delay) {
  36. let timeoutId = null;
  37. return function (...args) {
  38. if (timeoutId) {
  39. clearTimeout(timeoutId);
  40. }
  41. timeoutId = setTimeout(() => {
  42. f.apply(this, args);
  43. }, delay);
  44. };
  45. }
  46.  
  47. function createCopyButton(fileElement) {
  48. const fileActionElement = fileElement.querySelector('.file-actions');
  49. if (!fileActionElement) {
  50. return noop;
  51. }
  52.  
  53. const rawButton = fileActionElement.querySelector('a[href*="/raw/"]');
  54. if (!rawButton) {
  55. return noop;
  56. }
  57. const button = document.createElement('button');
  58. button.className = 'btn-octicon gist-copy-button';
  59. button.style.marginRight = '5px';
  60.  
  61. button.innerHTML = `
  62. <svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-copy">
  63. <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path>
  64. </svg>
  65.  
  66. <svg style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" class="gist-spinner">
  67. <path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity="0.25"/>
  68. <path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" class="gist-copy-spinner"/>
  69. </svg>
  70.  
  71. <svg style="display: none;" aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check color-fg-success">
  72. <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
  73. </svg>
  74. `;
  75.  
  76. const copyIcon = button.querySelector('.octicon-copy');
  77. const spinnerIcon = button.querySelector('.gist-spinner');
  78. const checkIcon = button.querySelector('.octicon-check');
  79.  
  80. let timeoutId = null;
  81. const copyHandler = (e) => {
  82. if (timeoutId) {
  83. return;
  84. }
  85. e.preventDefault();
  86.  
  87. copyIcon.style.display = 'none';
  88. spinnerIcon.style.display = 'inline-block';
  89.  
  90. const rawUrl = rawButton.href;
  91. const proxiedUrl = `https://api.codetabs.com/v1/proxy/?quest=${encodeURIComponent(rawUrl)}`;
  92. GM_xmlhttpRequest({
  93. method: 'GET',
  94. url: proxiedUrl,
  95. headers: {
  96. "Accept": "text/plain, application/json, */*"
  97. },
  98. followRedirect: true,
  99. onload: function(response) {
  100. if (response.status === 200) {
  101. if (response.responseText.includes("<a href=") && response.responseText.includes("Moved Permanently")) {
  102. console.error("Received redirect instead of content:", response.responseText);
  103. const match = response.responseText.match(/href="([^"]+)"/);
  104. if (match && match[1]) {
  105. const redirectUrl = match[1].startsWith("/")
  106. ? `https://api.codetabs.com${match[1]}`
  107. : match[1];
  108. GM_xmlhttpRequest({
  109. method: 'GET',
  110. url: redirectUrl,
  111. headers: {
  112. "Accept": "text/plain, application/json, */*"
  113. },
  114. onload: function(redirectResponse) {
  115. if (redirectResponse.status === 200) {
  116. GM_setClipboard(redirectResponse.responseText, { type: 'text', mimetype: 'text/plain' });
  117. spinnerIcon.style.display = 'none';
  118. checkIcon.style.display = 'inline-block';
  119. timeoutId = setTimeout(() => {
  120. checkIcon.style.display = 'none';
  121. copyIcon.style.display = 'inline-block';
  122. timeoutId = null;
  123. }, 500);
  124. } else {
  125. console.error("Failed to follow redirect:", redirectResponse.status);
  126. spinnerIcon.style.display = 'none';
  127. copyIcon.style.display = 'inline-block';
  128. timeoutId = null;
  129. }
  130. },
  131. onerror: function(error) {
  132. console.error("Error following redirect:", error);
  133. spinnerIcon.style.display = 'none';
  134. copyIcon.style.display = 'inline-block';
  135. timeoutId = null;
  136. }
  137. });
  138. } else {
  139. spinnerIcon.style.display = 'none';
  140. copyIcon.style.display = 'inline-block';
  141. timeoutId = null;
  142. }
  143. } else {
  144. GM_setClipboard(response.responseText, { type: 'text', mimetype: 'text/plain' });
  145. spinnerIcon.style.display = 'none';
  146. checkIcon.style.display = 'inline-block';
  147. timeoutId = setTimeout(() => {
  148. checkIcon.style.display = 'none';
  149. copyIcon.style.display = 'inline-block';
  150. timeoutId = null;
  151. }, 500);
  152. }
  153. } else {
  154. console.error("Error response status:", response.status);
  155. spinnerIcon.style.display = 'none';
  156. copyIcon.style.display = 'inline-block';
  157. timeoutId = null;
  158. }
  159. },
  160. onerror: function(error) {
  161. console.error('Error fetching gist content:', error);
  162. spinnerIcon.style.display = 'none';
  163. copyIcon.style.display = 'inline-block';
  164. timeoutId = null;
  165. }
  166. });
  167. };
  168.  
  169. button.addEventListener('click', copyHandler);
  170. fileActionElement.insertBefore(button, fileActionElement.firstChild);
  171.  
  172. return () => {
  173. button.removeEventListener('click', copyHandler);
  174. if (timeoutId) {
  175. clearTimeout(timeoutId);
  176. }
  177. button.remove();
  178. };
  179. }
  180.  
  181. function runGistCopy() {
  182. let removeAllListeners = noop;
  183. function tryCreateCopyButtons() {
  184. removeAllListeners();
  185. const fileElements = [...document.querySelectorAll('.file')];
  186. const removeListeners = fileElements.map(createCopyButton);
  187. removeAllListeners = () => {
  188. removeListeners.map((f) => f());
  189. [...document.querySelectorAll('.gist-copy-button')].forEach((el) => {
  190. el.remove();
  191. });
  192. };
  193. }
  194.  
  195. setTimeout(tryCreateCopyButtons, 300);
  196.  
  197. const observer = new MutationObserver(debounce(() => {
  198. if (document.querySelectorAll('.file').length > 0 &&
  199. document.querySelectorAll('.gist-copy-button').length === 0) {
  200. tryCreateCopyButtons();
  201. }
  202. }, 100));
  203.  
  204. observer.observe(document.body, {
  205. childList: true,
  206. subtree: true
  207. });
  208. if (window.onurlchange === null) {
  209. window.addEventListener('urlchange', debounce(tryCreateCopyButtons, 16));
  210. }
  211. }
  212.  
  213. runGistCopy();
  214. })();