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.

Installera detta skript?
Författaren's rekommenderade skript

Du kanske också gillar Confluence: space avatar as tab icon.

Installera detta skript
// ==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("[&#124;]", "Copy Jira syntax link", jiraSyntaxLink);
			insertBefore(markdownListItem, target);
			insertBefore(jiraListItem, target);
			log('Created buttons');
		});
	}

	try {
		createButtons();
	} catch (e) {
		error('Could not create buttons', e);
	}
})();