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-07 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Confluence: copy link buttons
  3. // @namespace https://github.com/rybak
  4. // @version 1
  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. // might need to actually look up the "Watch this page" button in live HTML to find actual CSS classes
  155. return htmlToElement(
  156. `<button class="_701w1ul9 css-hxlay7" type="button">
  157. <span class="css-16j5qb5" title="${title}">
  158. <span role="img" style="--icon-primary-color: currentColor; --icon-secondary-color: var(--ds-surface, #FFFFFF); ${customCss}" class="css-snhnyn">
  159. ${icon}${text}
  160. </span>
  161. </span>
  162. </button>`
  163. );
  164. }
  165.  
  166. function copyButton(text, title, plainTextFn) {
  167. const onclick = (event) => copyClickAction(event, plainTextFn);
  168.  
  169. return onVersion(
  170. () => {
  171. const copyButtonAnchor = htmlToElement(selfHostedButtonHtml(text, title));
  172. copyButtonAnchor.onclick = onclick;
  173. const copyButtonListItem = htmlToElement('<li class="ajs-button normal"></li>');
  174. copyButtonListItem.appendChild(copyButtonAnchor);
  175. return copyButtonListItem;
  176. },
  177. () => {
  178. const button = cloudButtonHtml(text, title);
  179. button.onclick = onclick;
  180. return button;
  181. }
  182. );
  183. }
  184.  
  185. function htmlSyntaxLink(url, pageTitle) {
  186. const html = `<a href="${url}">${pageTitle}</a>`;
  187. return html;
  188. }
  189.  
  190. function markdownSyntaxLink(url, pageTitle) {
  191. return `[${pageTitle}](${url})`;
  192. }
  193.  
  194. function jiraSyntaxLink(url, pageTitle) {
  195. return `[${pageTitle}|${url}]`;
  196. }
  197.  
  198. function insertBefore(newElem, oldElem) {
  199. oldElem.parentNode.insertBefore(newElem, oldElem);
  200. }
  201.  
  202. // from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
  203. function waitForElement(selector) {
  204. return new Promise(resolve => {
  205. if (document.querySelector(selector)) {
  206. return resolve(document.querySelector(selector));
  207. }
  208. const observer = new MutationObserver(mutations => {
  209. if (document.querySelector(selector)) {
  210. resolve(document.querySelector(selector));
  211. observer.disconnect();
  212. }
  213. });
  214.  
  215. observer.observe(document.body, {
  216. childList: true,
  217. subtree: true
  218. });
  219. });
  220. }
  221.  
  222. function createButtons() {
  223. onVersion(
  224. () => waitForElement('#action-menu-link'),
  225. () => waitForElement('button[aria-label="Share"]').then(shareButton => {
  226. // HTML of Cloud version is weird, lots of nesting and wrapping
  227. return shareButton.parentNode.parentNode.parentNode.parentNode;
  228. })
  229. ).then(target => {
  230. /*
  231. * Buttons are added to the left of the `target` element.
  232. */
  233. log('target', target);
  234. const markdownListItem = copyButton("[]()", "Copy Markdown link", markdownSyntaxLink);
  235. const jiraListItem = copyButton("[&#124;]", "Copy Jira syntax link", jiraSyntaxLink);
  236. insertBefore(markdownListItem, target);
  237. insertBefore(jiraListItem, target);
  238. log('Created buttons');
  239. });
  240. }
  241.  
  242. try {
  243. createButtons();
  244. } catch (e) {
  245. error('Could not create buttons', e);
  246. }
  247. })();