- // ==UserScript==
- // @name Confluence: copy link buttons
- // @namespace https://github.com/rybak
- // @version 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.
- // @author Andrei Rybak
- // @license MIT
- // @homepageURL https://github.com/rybak/atlassian-tweaks
- // @include https://confluence*
- // @match https://confluence.example.com
- // @icon https://seeklogo.com/images/C/confluence-logo-D9B07137C2-seeklogo.com.png
- // @grant none
- // ==/UserScript==
-
- /*
- * Copyright (c) 2023-2024 Andrei Rybak
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
- (function() {
- 'use strict';
-
- const LOG_PREFIX = '[Confluence copy link buttons]:';
-
- function log(...toLog) {
- console.log(LOG_PREFIX, ...toLog);
- }
-
- function error(...toLog) {
- console.error(LOG_PREFIX, ...toLog);
- }
-
- function cloudCopyIcon() {
- // icon similar to the achnor "Copy link" under the button "Share"
- 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>';
- }
-
- /*
- * Calls one of the parameters, based on the version of Confluence running.
- * This is needed to account for the differences in HTML and CSS.
- *
- * Tested on versions:
- * - Confluence Server 7.13.*
- * - Confluence Server 7.19.*
- * - Confluence Cloud 1000.0.0-22300355ddad (a free version on https://atlassian.net as of 2023-06-19)
- */
- function onVersion(selfHostedFn, cloudFn) {
- if (document.querySelector('meta[name=ajs-cloud-id]')) {
- // It would seem that all Cloud instances of Confluece have this <meta> tag.
- return cloudFn();
- }
- /*
- * Try to parse version number hidden in the <meta> tags.
- * Assume Confluence Cloud, if can't parse.
- */
- const maybeVersionElem = document.querySelector('meta[name=ajs-version-number]');
- if (maybeVersionElem) {
- const majorVersion = parseInt(maybeVersionElem.content);
- if (isNaN(majorVersion)) {
- log("Cannot parse major version", maybeVersionElem.content);
- return cloudFn();
- }
- if (majorVersion >= 1000) {
- return cloudFn();
- } else {
- return selfHostedFn();
- }
- } else {
- log("Couldn't find meta tag with version");
- return cloudFn();
- }
- }
-
- function addLinkToClipboard(event, plainText, html) {
- event.stopPropagation();
- event.preventDefault();
-
- let clipboardData = event.clipboardData || window.clipboardData;
- clipboardData.setData('text/plain', plainText);
- clipboardData.setData('text/html', html);
- }
-
- function copyClickAction(event, plainTextFn) {
- event.preventDefault();
- try {
- let pageTitle = null;
- try {
- pageTitle = document.querySelector('meta[name="ajs-page-title"]').content;
- } catch (ignored) {
- }
- if (!pageTitle) {
- try {
- // `AJS` is defined in Confluence's own JS
- pageTitle = AJS.Data.get('page-title');
- } catch (e) {
- error('Could not get the page title. Aborting.', e);
- return;
- }
- }
- const url = document.location.href;
- /*
- * Using both plain text and HTML ("rich text") means that the copied links
- * can be inserted both in plain text inputs (Jira syntax – for Jira, Markdown
- * syntax – for Bitbucket, GitHub, etc) and in rich text inputs, such as
- * Microsoft Word, Slack, etc.
- */
- const plainText = plainTextFn(url, pageTitle);
- const html = htmlSyntaxLink(url, pageTitle);
-
- const handleCopyEvent = e => {
- addLinkToClipboard(e, plainText, html);
- };
- document.addEventListener('copy', handleCopyEvent);
- document.execCommand('copy');
- document.removeEventListener('copy', handleCopyEvent);
- } catch (e) {
- error('Could not do the copying', e);
- }
- }
-
- // adapted from https://stackoverflow.com/a/35385518/1083697 by Mark Amery
- function htmlToElement(html) {
- const template = document.createElement('template');
- template.innerHTML = html.trim();
- return template.content.firstChild;
- }
-
- function selfHostedButtonHtml(text, title) {
- const icon = '<span class="aui-icon aui-icon-small aui-iconfont-copy"></span>';
- return `<a href="#" class="aui-button aui-button-subtle" title="${title}"><span>${icon}${text}</span></a>`;
- }
-
- function cloudButtonHtml(text, title) {
- const icon = cloudCopyIcon();
- // Custom CSS is needed to make the ${text} readable.
- const customCss = 'font-size: 16px; line-height: 26px;';
- // HTML & CSS classes from the "Watch this page" button
- const watchThisPageButton = document.querySelector('[data-id="page-watch-button"]');
- const buttonClasses = watchThisPageButton.className;
- const innerSpanClasses = watchThisPageButton.children[0].className;
- const innerInnerSpanClasses = watchThisPageButton.children[0].children[0].className;
- return htmlToElement(
- `<button class="${buttonClasses}" type="button">
- <span class="${innerSpanClasses}" title="${title}">
- <span class="${innerInnerSpanClasses}" role="img" style="--icon-primary-color: currentColor; --icon-secondary-color: var(--ds-surface, #FFFFFF); ${customCss}">
- ${icon}${text}
- </span>
- </span>
- </button>`
- );
- }
-
- function copyButton(text, title, plainTextFn) {
- const onclick = (event) => copyClickAction(event, plainTextFn);
-
- return onVersion(
- () => {
- const copyButtonAnchor = htmlToElement(selfHostedButtonHtml(text, title));
- copyButtonAnchor.onclick = onclick;
- const copyButtonListItem = htmlToElement('<li class="ajs-button normal"></li>');
- copyButtonListItem.appendChild(copyButtonAnchor);
- return copyButtonListItem;
- },
- () => {
- const button = cloudButtonHtml(text, title);
- button.onclick = onclick;
- return button;
- }
- );
- }
-
- function htmlSyntaxLink(url, pageTitle) {
- const html = `<a href="${url}">${pageTitle}</a>`;
- return html;
- }
-
- function markdownSyntaxLink(url, pageTitle) {
- return `[${pageTitle}](${url})`;
- }
-
- function jiraSyntaxLink(url, pageTitle) {
- return `[${pageTitle}|${url}]`;
- }
-
- function insertBefore(newElem, oldElem) {
- oldElem.parentNode.insertBefore(newElem, oldElem);
- }
-
- // from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
- function waitForElement(selector) {
- return new Promise(resolve => {
- if (document.querySelector(selector)) {
- return resolve(document.querySelector(selector));
- }
- const observer = new MutationObserver(mutations => {
- if (document.querySelector(selector)) {
- resolve(document.querySelector(selector));
- observer.disconnect();
- }
- });
-
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
- });
- }
-
- function createButtons() {
- onVersion(
- () => waitForElement('#action-menu-link'),
- () => waitForElement('button[aria-label="Share"]').then(shareButton => {
- // HTML of Cloud version is weird, lots of nesting and wrapping
- return shareButton.parentNode.parentNode.parentNode.parentNode;
- })
- ).then(target => {
- /*
- * Buttons are added to the left of the `target` element.
- */
- log('target', target);
- const markdownListItem = copyButton("[]()", "Copy Markdown link", markdownSyntaxLink);
- const jiraListItem = copyButton("[|]", "Copy Jira syntax link", jiraSyntaxLink);
- insertBefore(markdownListItem, target);
- insertBefore(jiraListItem, target);
- log('Created buttons');
- });
- }
-
- try {
- createButtons();
- } catch (e) {
- error('Could not create buttons', e);
- }
- })();