Confluence: copy link buttons

Adds buttons to copy a link to the current page directly into clipboard. Two buttons are supported: Markdown and Jira syntax. Both buttons support HTML for rich text editors.

目前为 2023-07-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Confluence: copy link buttons
  3. // @namespace https://github.com/rybak
  4. // @version 3
  5. // @description Adds buttons to copy a link to the current page directly into clipboard. Two buttons are supported: Markdown and Jira syntax. Both buttons support HTML for rich text editors.
  6. // @author Andrei Rybak
  7. // @homepageURL https://github.com/rybak/atlassian-tweaks
  8. // @include https://confluence*
  9. // @match https://confluence.example.com
  10. // @icon https://seeklogo.com/images/C/confluence-logo-D9B07137C2-seeklogo.com.png
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. /*
  15. * Copyright (c) 2023 Andrei Rybak
  16. *
  17. * Permission is hereby granted, free of charge, to any person obtaining a copy
  18. * of this software and associated documentation files (the "Software"), to deal
  19. * in the Software without restriction, including without limitation the rights
  20. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  21. * copies of the Software, and to permit persons to whom the Software is
  22. * furnished to do so, subject to the following conditions:
  23. *
  24. * The above copyright notice and this permission notice shall be included in all
  25. * copies or substantial portions of the Software.
  26. *
  27. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  28. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  29. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  30. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  31. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  32. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  33. * SOFTWARE.
  34. */
  35.  
  36. (function() {
  37. 'use strict';
  38.  
  39. const LOG_PREFIX = '[Confluence copy link buttons]:';
  40.  
  41. function log(...toLog) {
  42. console.log(LOG_PREFIX, ...toLog);
  43. }
  44.  
  45. function error(...toLog) {
  46. console.error(LOG_PREFIX, ...toLog);
  47. }
  48.  
  49. function cloudCopyIcon() {
  50. // icon similar to the achnor "Copy link" under the button "Share"
  51. return '<svg width="24" height="24" viewBox="0 0 24 24" role="presentation"><path d="M12.654 8.764a.858.858 0 01-1.213-1.213l1.214-1.214a3.717 3.717 0 015.257 0 3.714 3.714 0 01.001 5.258l-1.214 1.214-.804.804a3.72 3.72 0 01-5.263.005.858.858 0 011.214-1.214c.781.782 2.05.78 2.836-.005l.804-.803 1.214-1.214a1.998 1.998 0 00-.001-2.831 2 2 0 00-2.83 0l-1.215 1.213zm-.808 6.472a.858.858 0 011.213 1.213l-1.214 1.214a3.717 3.717 0 01-5.257 0 3.714 3.714 0 01-.001-5.258l1.214-1.214.804-.804a3.72 3.72 0 015.263-.005.858.858 0 01-1.214 1.214 2.005 2.005 0 00-2.836.005l-.804.803L7.8 13.618a1.998 1.998 0 00.001 2.831 2 2 0 002.83 0l1.215-1.213z" fill="currentColor"></path></svg>';
  52. }
  53.  
  54. /*
  55. * Calls one of the parameters, based on the version of Confluence running.
  56. * This is needed to account for the differences in HTML and CSS.
  57. *
  58. * Tested on versions:
  59. * - Confluence Server 7.13.*
  60. * - Confluence Server 7.19.*
  61. * - Confluence Cloud 1000.0.0-22300355ddad (a free version on https://atlassian.net as of 2023-06-19)
  62. */
  63. function onVersion(selfHostedFn, cloudFn) {
  64. if (document.querySelector('meta[name=ajs-cloud-id]')) {
  65. // It would seem that all Cloud instances of Confluece have this <meta> tag.
  66. return cloudFn();
  67. }
  68. /*
  69. * Try to parse version number hidden in the <meta> tags.
  70. * Assume Confluence Cloud, if can't parse.
  71. */
  72. const maybeVersionElem = document.querySelector('meta[name=ajs-version-number]');
  73. if (maybeVersionElem) {
  74. const majorVersion = parseInt(maybeVersionElem.content);
  75. if (isNaN(majorVersion)) {
  76. log("Couldn't parse major version", e);
  77. return cloudFn();
  78. }
  79. if (majorVersion >= 1000) {
  80. return cloudFn();
  81. } else {
  82. return selfHostedFn();
  83. }
  84. } else {
  85. log("Couldn't find meta tag with version");
  86. return cloudFn();
  87. }
  88. }
  89.  
  90. function addLinkToClipboard(event, plainText, html) {
  91. event.stopPropagation();
  92. event.preventDefault();
  93.  
  94. let clipboardData = event.clipboardData || window.clipboardData;
  95. clipboardData.setData('text/plain', plainText);
  96. clipboardData.setData('text/html', html);
  97. }
  98.  
  99. function copyClickAction(event, plainTextFn) {
  100. event.preventDefault();
  101. try {
  102. let pageTitle = null;
  103. try {
  104. pageTitle = document.querySelector('meta[name="ajs-page-title"]').content;
  105. } catch (ignored) {
  106. }
  107. if (!pageTitle) {
  108. try {
  109. // `AJS` is defined in Confluence's own JS
  110. pageTitle = AJS.Data.get('page-title');
  111. } catch (e) {
  112. error('Could not get the page title. Aborting.', e);
  113. return;
  114. }
  115. }
  116. const url = document.location.href;
  117. /*
  118. * Using both plain text and HTML ("rich text") means that the copied links
  119. * can be inserted both in plain text inputs (Jira syntax – for Jira, Markdown
  120. * syntax – for Bitbucket, GitHub, etc) and in rich text inputs, such as
  121. * Microsoft Word, Slack, etc.
  122. */
  123. const plainText = plainTextFn(url, pageTitle);
  124. const html = htmlSyntaxLink(url, pageTitle);
  125.  
  126. const handleCopyEvent = e => {
  127. addLinkToClipboard(e, plainText, html);
  128. };
  129. document.addEventListener('copy', handleCopyEvent);
  130. document.execCommand('copy');
  131. document.removeEventListener('copy', handleCopyEvent);
  132. } catch (e) {
  133. error('Could not do the copying', e);
  134. }
  135. }
  136.  
  137. // adapted from https://stackoverflow.com/a/35385518/1083697 by Mark Amery
  138. function htmlToElement(html) {
  139. const template = document.createElement('template');
  140. template.innerHTML = html.trim();
  141. return template.content.firstChild;
  142. }
  143.  
  144. function selfHostedButtonHtml(text, title) {
  145. const icon = '<span class="aui-icon aui-icon-small aui-iconfont-copy"></span>';
  146. return `<a href="#" class="aui-button aui-button-subtle" title="${title}"><span>${icon}${text}</span></a>`;
  147. }
  148.  
  149. function cloudButtonHtml(text, title) {
  150. const icon = cloudCopyIcon();
  151. // Custom CSS is needed to make the ${text} readable.
  152. const customCss = 'font-size: 16px; line-height: 26px;';
  153. // HTML & CSS classes from the "Watch this page" button
  154. const watchThisPageButton = document.querySelector('[data-id="page-watch-button"]');
  155. const buttonClasses = watchThisPageButton.className;
  156. const innerSpanClasses = watchThisPageButton.children[0].className;
  157. const innerInnerSpanClasses = watchThisPageButton.children[0].children[0].className;
  158. return htmlToElement(
  159. `<button class="${buttonClasses}" type="button">
  160. <span class="${innerSpanClasses}" title="${title}">
  161. <span class="${innerInnerSpanClasses}" role="img" style="--icon-primary-color: currentColor; --icon-secondary-color: var(--ds-surface, #FFFFFF); ${customCss}">
  162. ${icon}${text}
  163. </span>
  164. </span>
  165. </button>`
  166. );
  167. }
  168.  
  169. function copyButton(text, title, plainTextFn) {
  170. const onclick = (event) => copyClickAction(event, plainTextFn);
  171.  
  172. return onVersion(
  173. () => {
  174. const copyButtonAnchor = htmlToElement(selfHostedButtonHtml(text, title));
  175. copyButtonAnchor.onclick = onclick;
  176. const copyButtonListItem = htmlToElement('<li class="ajs-button normal"></li>');
  177. copyButtonListItem.appendChild(copyButtonAnchor);
  178. return copyButtonListItem;
  179. },
  180. () => {
  181. const button = cloudButtonHtml(text, title);
  182. button.onclick = onclick;
  183. return button;
  184. }
  185. );
  186. }
  187.  
  188. function htmlSyntaxLink(url, pageTitle) {
  189. const html = `<a href="${url}">${pageTitle}</a>`;
  190. return html;
  191. }
  192.  
  193. function markdownSyntaxLink(url, pageTitle) {
  194. return `[${pageTitle}](${url})`;
  195. }
  196.  
  197. function jiraSyntaxLink(url, pageTitle) {
  198. return `[${pageTitle}|${url}]`;
  199. }
  200.  
  201. function insertBefore(newElem, oldElem) {
  202. oldElem.parentNode.insertBefore(newElem, oldElem);
  203. }
  204.  
  205. // from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
  206. function waitForElement(selector) {
  207. return new Promise(resolve => {
  208. if (document.querySelector(selector)) {
  209. return resolve(document.querySelector(selector));
  210. }
  211. const observer = new MutationObserver(mutations => {
  212. if (document.querySelector(selector)) {
  213. resolve(document.querySelector(selector));
  214. observer.disconnect();
  215. }
  216. });
  217.  
  218. observer.observe(document.body, {
  219. childList: true,
  220. subtree: true
  221. });
  222. });
  223. }
  224.  
  225. function createButtons() {
  226. onVersion(
  227. () => waitForElement('#action-menu-link'),
  228. () => waitForElement('button[aria-label="Share"]').then(shareButton => {
  229. // HTML of Cloud version is weird, lots of nesting and wrapping
  230. return shareButton.parentNode.parentNode.parentNode.parentNode;
  231. })
  232. ).then(target => {
  233. /*
  234. * Buttons are added to the left of the `target` element.
  235. */
  236. log('target', target);
  237. const markdownListItem = copyButton("[]()", "Copy Markdown link", markdownSyntaxLink);
  238. const jiraListItem = copyButton("[&#124;]", "Copy Jira syntax link", jiraSyntaxLink);
  239. insertBefore(markdownListItem, target);
  240. insertBefore(jiraListItem, target);
  241. log('Created buttons');
  242. });
  243. }
  244.  
  245. try {
  246. createButtons();
  247. } catch (e) {
  248. error('Could not create buttons', e);
  249. }
  250. })();