| 1 | efrain | 1 | // This file is part of Moodle - http://moodle.org/
 | 
        
           |  |  | 2 | //
 | 
        
           |  |  | 3 | // Moodle is free software: you can redistribute it and/or modify
 | 
        
           |  |  | 4 | // it under the terms of the GNU General Public License as published by
 | 
        
           |  |  | 5 | // the Free Software Foundation, either version 3 of the License, or
 | 
        
           |  |  | 6 | // (at your option) any later version.
 | 
        
           |  |  | 7 | //
 | 
        
           |  |  | 8 | // Moodle is distributed in the hope that it will be useful,
 | 
        
           |  |  | 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
        
           |  |  | 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
        
           |  |  | 11 | // GNU General Public License for more details.
 | 
        
           |  |  | 12 | //
 | 
        
           |  |  | 13 | // You should have received a copy of the GNU General Public License
 | 
        
           |  |  | 14 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | 
        
           |  |  | 15 |   | 
        
           |  |  | 16 | /**
 | 
        
           |  |  | 17 |  * A JavaScript module that enhances a button and text container to support copy-to-clipboard functionality.
 | 
        
           |  |  | 18 |  *
 | 
        
           |  |  | 19 |  * This module needs to be loaded by pages/templates/modules that require this functionality.
 | 
        
           |  |  | 20 |  *
 | 
        
           |  |  | 21 |  * To enable copy-to-clipboard functionality, we need a trigger element (usually a button) and a copy target element
 | 
        
           |  |  | 22 |  * (e.g. a div, span, text input, or text area).
 | 
        
           |  |  | 23 |  *
 | 
        
           |  |  | 24 |  * In the trigger element, we need to declare the <code>data-action="copytoclipboard"</code> attribute and set the
 | 
        
           |  |  | 25 |  * <code>data-clipboard-target</code> attribute which is the CSS selector that points to the target element that contains the text
 | 
        
           |  |  | 26 |  * to be copied.
 | 
        
           |  |  | 27 |  *
 | 
        
           |  |  | 28 |  * When the text is successfully copied to the clipboard, a toast message that indicates that the copy operation was a success
 | 
        
           |  |  | 29 |  * will be shown. This success message can be customised by setting the <code>data-clipboard-success-message</code> attribute in the
 | 
        
           |  |  | 30 |  * trigger element.
 | 
        
           |  |  | 31 |  *
 | 
        
           |  |  | 32 |  * @module     core/copy_to_clipboard
 | 
        
           |  |  | 33 |  * @copyright  2021 Jun Pataleta
 | 
        
           |  |  | 34 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 35 |  *
 | 
        
           |  |  | 36 |  * @example <caption>Markup for the trigger and target elements</caption>
 | 
        
           |  |  | 37 |  * <input type="text" id="textinputtocopy" class="form-control" value="Copy me!" readonly />
 | 
        
           |  |  | 38 |  * <button id="copybutton" data-action="copytoclipboard" data-clipboard-target="#textinputtocopy"
 | 
        
           |  |  | 39 |  *         data-clipboard-success-message="Success!" class="btn btn-secondary">
 | 
        
           |  |  | 40 |  *     Copy to clipboard
 | 
        
           |  |  | 41 |  * </button>
 | 
        
           |  |  | 42 |  */
 | 
        
           |  |  | 43 | import {getString} from 'core/str';
 | 
        
           |  |  | 44 | import {add as addToast} from 'core/toast';
 | 
        
           |  |  | 45 | import {prefetchStrings} from 'core/prefetch';
 | 
        
           |  |  | 46 |   | 
        
           |  |  | 47 | /**
 | 
        
           |  |  | 48 |  * Add event listeners to trigger elements through event delegation.
 | 
        
           |  |  | 49 |  *
 | 
        
           |  |  | 50 |  * @private
 | 
        
           |  |  | 51 |  */
 | 
        
           |  |  | 52 | const addEventListeners = () => {
 | 
        
           |  |  | 53 |     document.addEventListener('click', e => {
 | 
        
           |  |  | 54 |         const copyButton = e.target.closest('[data-action="copytoclipboard"]');
 | 
        
           |  |  | 55 |         if (!copyButton) {
 | 
        
           |  |  | 56 |             return;
 | 
        
           |  |  | 57 |         }
 | 
        
           |  |  | 58 |   | 
        
           |  |  | 59 |         if (!copyButton.dataset.clipboardTarget) {
 | 
        
           |  |  | 60 |             return;
 | 
        
           |  |  | 61 |         }
 | 
        
           |  |  | 62 |   | 
        
           |  |  | 63 |         const copyTarget = document.querySelector(copyButton.dataset.clipboardTarget);
 | 
        
           |  |  | 64 |         if (!copyTarget) {
 | 
        
           |  |  | 65 |             return;
 | 
        
           |  |  | 66 |         }
 | 
        
           |  |  | 67 |   | 
        
           |  |  | 68 |         // This is a copy target and there is content.
 | 
        
           |  |  | 69 |         // Prevent the default action.
 | 
        
           |  |  | 70 |         e.preventDefault();
 | 
        
           |  |  | 71 |   | 
        
           |  |  | 72 |         // We have a copy target - great. Let's copy its content.
 | 
        
           |  |  | 73 |         const textToCopy = getTextFromContainer(copyTarget);
 | 
        
           |  |  | 74 |         if (!textToCopy) {
 | 
        
           |  |  | 75 |             displayFailureToast();
 | 
        
           |  |  | 76 |             return;
 | 
        
           |  |  | 77 |         }
 | 
        
           |  |  | 78 |   | 
        
           |  |  | 79 |         if (navigator.clipboard) {
 | 
        
           |  |  | 80 |             navigator.clipboard.writeText(textToCopy)
 | 
        
           |  |  | 81 |                 .then(() => displaySuccessToast(copyButton)).catch();
 | 
        
           |  |  | 82 |   | 
        
           |  |  | 83 |             return;
 | 
        
           |  |  | 84 |         }
 | 
        
           |  |  | 85 |   | 
        
           |  |  | 86 |         // The clipboard API is not available.
 | 
        
           |  |  | 87 |         // This may happen when the page is not served over SSL.
 | 
        
           |  |  | 88 |         // Try to fall back to document.execCommand() approach of copying the text.
 | 
        
           |  |  | 89 |         // WARNING: This is deprecated functionality that may get dropped at anytime by browsers.
 | 
        
           |  |  | 90 |   | 
        
           |  |  | 91 |         if (copyTarget instanceof HTMLInputElement || copyTarget instanceof HTMLTextAreaElement) {
 | 
        
           |  |  | 92 |             // Focus and select the text in the target element.
 | 
        
           |  |  | 93 |             // If the execCommand fails, at least the user can readily copy the text.
 | 
        
           |  |  | 94 |             copyTarget.focus();
 | 
        
           |  |  | 95 |   | 
        
           |  |  | 96 |             if (copyNodeContentToClipboard(copyButton, copyTarget)) {
 | 
        
           |  |  | 97 |                 // If the copy was successful then focus back on the copy button.
 | 
        
           |  |  | 98 |                 copyButton.focus();
 | 
        
           |  |  | 99 |             }
 | 
        
           |  |  | 100 |         } else {
 | 
        
           |  |  | 101 |             // This copyTarget is not an input, or text area so cannot be used with the execCommand('copy') command.
 | 
        
           |  |  | 102 |             // To work around this we create a new textarea and copy that.
 | 
        
           |  |  | 103 |             // This textarea must be part of the DOM and must be visible.
 | 
        
           | 1441 | ariadna | 104 |             // We (ab)use the visually-hidden tag to ensure that it is considered visible to the browser, whilst being
 | 
        
           | 1 | efrain | 105 |             // hidden from view by the user.
 | 
        
           |  |  | 106 |             const copyRegion = document.createElement('textarea');
 | 
        
           |  |  | 107 |             copyRegion.value = textToCopy;
 | 
        
           | 1441 | ariadna | 108 |             copyRegion.classList.add('visually-hidden');
 | 
        
           | 1 | efrain | 109 |             document.body.appendChild(copyRegion);
 | 
        
           |  |  | 110 |   | 
        
           |  |  | 111 |             copyNodeContentToClipboard(copyButton, copyRegion);
 | 
        
           |  |  | 112 |   | 
        
           |  |  | 113 |             // After copying, remove the temporary element and move focus back to the triggering button.
 | 
        
           |  |  | 114 |             copyRegion.remove();
 | 
        
           |  |  | 115 |             copyButton.focus();
 | 
        
           |  |  | 116 |         }
 | 
        
           |  |  | 117 |     });
 | 
        
           |  |  | 118 | };
 | 
        
           |  |  | 119 |   | 
        
           |  |  | 120 | /**
 | 
        
           |  |  | 121 |  * Copy the content of the selected element to the clipboard, and display a notifiction if successful.
 | 
        
           |  |  | 122 |  *
 | 
        
           |  |  | 123 |  * @param {HTMLElement} copyButton
 | 
        
           |  |  | 124 |  * @param {HTMLElement} copyTarget
 | 
        
           |  |  | 125 |  * @returns {boolean}
 | 
        
           |  |  | 126 |  * @private
 | 
        
           |  |  | 127 |  */
 | 
        
           |  |  | 128 | const copyNodeContentToClipboard = (copyButton, copyTarget) => {
 | 
        
           |  |  | 129 |     copyTarget.select();
 | 
        
           |  |  | 130 |   | 
        
           |  |  | 131 |     // Try to copy the text from the target element.
 | 
        
           |  |  | 132 |     if (document.execCommand('copy')) {
 | 
        
           |  |  | 133 |         displaySuccessToast(copyButton);
 | 
        
           |  |  | 134 |         return true;
 | 
        
           |  |  | 135 |     }
 | 
        
           |  |  | 136 |   | 
        
           |  |  | 137 |     displayFailureToast();
 | 
        
           |  |  | 138 |     return false;
 | 
        
           |  |  | 139 | };
 | 
        
           |  |  | 140 |   | 
        
           |  |  | 141 | /**
 | 
        
           |  |  | 142 |  * Displays a toast containing the success message.
 | 
        
           |  |  | 143 |  *
 | 
        
           |  |  | 144 |  * @param {HTMLElement} copyButton The element that copies the text from the container.
 | 
        
           |  |  | 145 |  * @returns {Promise<void>}
 | 
        
           |  |  | 146 |  * @private
 | 
        
           |  |  | 147 |  */
 | 
        
           |  |  | 148 | const displaySuccessToast = copyButton => getSuccessText(copyButton)
 | 
        
           |  |  | 149 |     .then(successMessage => addToast(successMessage, {}));
 | 
        
           |  |  | 150 |   | 
        
           |  |  | 151 | /**
 | 
        
           |  |  | 152 |  * Displays a toast containing the failure message.
 | 
        
           |  |  | 153 |  *
 | 
        
           |  |  | 154 |  * @returns {Promise<void>}
 | 
        
           |  |  | 155 |  * @private
 | 
        
           |  |  | 156 |  */
 | 
        
           |  |  | 157 | const displayFailureToast = () => getFailureText()
 | 
        
           |  |  | 158 |     .then(message => addToast(message, {type: 'warning'}));
 | 
        
           |  |  | 159 |   | 
        
           |  |  | 160 | /**
 | 
        
           |  |  | 161 |  * Fetches the failure message to show to the user.
 | 
        
           |  |  | 162 |  *
 | 
        
           |  |  | 163 |  * @returns {Promise}
 | 
        
           |  |  | 164 |  * @private
 | 
        
           |  |  | 165 |  */
 | 
        
           |  |  | 166 | const getFailureText = () => getString('unabletocopytoclipboard', 'core');
 | 
        
           |  |  | 167 |   | 
        
           |  |  | 168 | /**
 | 
        
           |  |  | 169 |  * Fetches the success message to show to the user.
 | 
        
           |  |  | 170 |  *
 | 
        
           |  |  | 171 |  * @param {HTMLElement} copyButton The element that copies the text from the container. This may contain the custom success message
 | 
        
           |  |  | 172 |  * via its data-clipboard-success-message attribute.
 | 
        
           |  |  | 173 |  * @returns {Promise|*}
 | 
        
           |  |  | 174 |  * @private
 | 
        
           |  |  | 175 |  */
 | 
        
           |  |  | 176 | const getSuccessText = copyButton => {
 | 
        
           |  |  | 177 |     if (copyButton.dataset.clipboardSuccessMessage) {
 | 
        
           |  |  | 178 |         return Promise.resolve(copyButton.dataset.clipboardSuccessMessage);
 | 
        
           |  |  | 179 |     }
 | 
        
           |  |  | 180 |   | 
        
           |  |  | 181 |     return getString('textcopiedtoclipboard', 'core');
 | 
        
           |  |  | 182 | };
 | 
        
           |  |  | 183 |   | 
        
           |  |  | 184 | /**
 | 
        
           |  |  | 185 |  * Fetches the text to be copied from the container.
 | 
        
           |  |  | 186 |  *
 | 
        
           |  |  | 187 |  * @param {HTMLElement} container The element containing the text to be copied.
 | 
        
           |  |  | 188 |  * @returns {null|string}
 | 
        
           |  |  | 189 |  * @private
 | 
        
           |  |  | 190 |  */
 | 
        
           |  |  | 191 | const getTextFromContainer = container => {
 | 
        
           |  |  | 192 |     if (container.value) {
 | 
        
           |  |  | 193 |         // For containers which are form elements (e.g. text area, text input), get the element's value.
 | 
        
           |  |  | 194 |         return container.value;
 | 
        
           |  |  | 195 |     } else if (container.innerText) {
 | 
        
           |  |  | 196 |         // For other elements, try to use the innerText attribute.
 | 
        
           |  |  | 197 |         return container.innerText;
 | 
        
           |  |  | 198 |     }
 | 
        
           |  |  | 199 |   | 
        
           |  |  | 200 |     return null;
 | 
        
           |  |  | 201 | };
 | 
        
           |  |  | 202 |   | 
        
           |  |  | 203 | let loaded = false;
 | 
        
           |  |  | 204 | if (!loaded) {
 | 
        
           |  |  | 205 |     prefetchStrings('core', [
 | 
        
           |  |  | 206 |         'textcopiedtoclipboard',
 | 
        
           |  |  | 207 |         'unabletocopytoclipboard',
 | 
        
           |  |  | 208 |     ]);
 | 
        
           |  |  | 209 |   | 
        
           |  |  | 210 |     // Add event listeners.
 | 
        
           |  |  | 211 |     addEventListeners();
 | 
        
           |  |  | 212 |     loaded = true;
 | 
        
           |  |  | 213 | }
 |