From 44df750c9fa5b573d1429159d9d63c48523d2e9c Mon Sep 17 00:00:00 2001 From: Beto Muniz Date: Tue, 18 May 2021 11:46:22 -0300 Subject: [PATCH] Isolate actions strategies in order to code improvement and programmatic usage. (#749) * Isolate cut, copy and core helper functions. * Update tests to accommodate new proposal * Add/update tests * Add tests to static copy/cut methods * Update condition syntax based on PR reviews * Migrate clipboard-action-default to functional approach. Update tests. Add tests * Improve folder structure. Clean up code. * Add types. Fix tsd check env. Improve in-code doc comments * Improve in-code doc comments --- .gitignore | 1 + demo/target-programmatic-copy.html | 28 ++ demo/target-programmatic-cut.html | 28 ++ demo/text-programmatic-copy.html | 27 ++ dist/clipboard.js | 460 ++++++++++++----------------- dist/clipboard.min.js | 2 +- src/actions/copy.js | 29 ++ src/actions/cut.js | 15 + src/actions/default.js | 53 ++++ src/clipboard-action.js | 221 -------------- src/clipboard.d.ts | 17 +- src/clipboard.js | 51 +++- src/clipboard.test-d.ts | 2 +- src/common/command.js | 12 + src/common/create-fake-element.js | 26 ++ test/actions/copy.js | 55 ++++ test/actions/cut.js | 32 ++ test/actions/default.js | 80 +++++ test/clipboard-action.js | 248 ---------------- test/clipboard.js | 70 ++++- test/common/command.js | 49 +++ test/common/create-fake-element.js | 13 + 22 files changed, 748 insertions(+), 771 deletions(-) create mode 100644 demo/target-programmatic-copy.html create mode 100644 demo/target-programmatic-cut.html create mode 100644 demo/text-programmatic-copy.html create mode 100644 src/actions/copy.js create mode 100644 src/actions/cut.js create mode 100644 src/actions/default.js delete mode 100644 src/clipboard-action.js create mode 100644 src/common/command.js create mode 100644 src/common/create-fake-element.js create mode 100644 test/actions/copy.js create mode 100644 test/actions/cut.js create mode 100644 test/actions/default.js delete mode 100644 test/clipboard-action.js create mode 100644 test/common/command.js create mode 100644 test/common/create-fake-element.js diff --git a/.gitignore b/.gitignore index ff4dcd8..d7aa998 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ bower_components node_modules yarn-error.log yarn.lock +.DS_Store diff --git a/demo/target-programmatic-copy.html b/demo/target-programmatic-copy.html new file mode 100644 index 0000000..0f00ded --- /dev/null +++ b/demo/target-programmatic-copy.html @@ -0,0 +1,28 @@ + + + + + target-programmatic-copy + + + + + + + + + + + + + + diff --git a/demo/target-programmatic-cut.html b/demo/target-programmatic-cut.html new file mode 100644 index 0000000..3b5e9f0 --- /dev/null +++ b/demo/target-programmatic-cut.html @@ -0,0 +1,28 @@ + + + + + target-programmatic-cut + + + + + + + + + + + + + + diff --git a/demo/text-programmatic-copy.html b/demo/text-programmatic-copy.html new file mode 100644 index 0000000..7cb7ed0 --- /dev/null +++ b/demo/text-programmatic-copy.html @@ -0,0 +1,27 @@ + + + + + text-programmatic-copy + + + + + + + + + + + + + diff --git a/dist/clipboard.js b/dist/clipboard.js index d54de7e..e98e221 100644 --- a/dist/clipboard.js +++ b/dist/clipboard.js @@ -17,7 +17,7 @@ return /******/ (function() { // webpackBootstrap /******/ var __webpack_modules__ = ({ -/***/ 134: +/***/ 747: /***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -36,265 +36,156 @@ var listen_default = /*#__PURE__*/__webpack_require__.n(listen); // EXTERNAL MODULE: ./node_modules/select/src/select.js var src_select = __webpack_require__(817); var select_default = /*#__PURE__*/__webpack_require__.n(src_select); -;// CONCATENATED MODULE: ./src/clipboard-action.js +;// CONCATENATED MODULE: ./src/common/command.js +/** + * Executes a given operation type. + * @param {String} type + * @return {Boolean} + */ +function command(type) { + try { + return document.execCommand(type); + } catch (err) { + return false; + } +} +;// CONCATENATED MODULE: ./src/clipboard-action-cut.js + + +/** + * Cut action wrapper. + * @param {HTMLElement} target + * @return {String} + */ + +var ClipboardActionCut = function ClipboardActionCut(target) { + var selectedText = select_default()(target); + command('cut'); + return selectedText; +}; + +/* harmony default export */ var clipboard_action_cut = (ClipboardActionCut); +;// CONCATENATED MODULE: ./src/common/create-fake-element.js +/** + * Creates a fake textarea element with a value. + * @param {String} value + * @return {HTMLElement} + */ +function createFakeElement(value) { + var isRTL = document.documentElement.getAttribute('dir') === 'rtl'; + var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS + + fakeElement.style.fontSize = '12pt'; // Reset box model + + fakeElement.style.border = '0'; + fakeElement.style.padding = '0'; + fakeElement.style.margin = '0'; // Move element out of screen horizontally + + fakeElement.style.position = 'absolute'; + fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically + + var yPosition = window.pageYOffset || document.documentElement.scrollTop; + fakeElement.style.top = "".concat(yPosition, "px"); + fakeElement.setAttribute('readonly', ''); + fakeElement.value = value; + return fakeElement; +} +;// CONCATENATED MODULE: ./src/clipboard-action-copy.js + + + +/** + * Copy action wrapper. + * @param {String|HTMLElement} target + * @param {Object} options + * @return {String} + */ + +var ClipboardActionCopy = function ClipboardActionCopy(target) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { + container: document.body + }; + var selectedText = ''; + + if (typeof target === 'string') { + var fakeElement = createFakeElement(target); + options.container.appendChild(fakeElement); + selectedText = select_default()(fakeElement); + command('copy'); + fakeElement.remove(); + } else { + selectedText = select_default()(target); + command('copy'); + } + + return selectedText; +}; + +/* harmony default export */ var clipboard_action_copy = (ClipboardActionCopy); +;// CONCATENATED MODULE: ./src/clipboard-action-default.js function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + + +/** + * Inner function which performs selection from either `text` or `target` + * properties and then executes copy or cut operations. + * @param {Object} options + */ + +var ClipboardActionDefault = function ClipboardActionDefault() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + // Defines base properties passed from constructor. + var _options$action = options.action, + action = _options$action === void 0 ? 'copy' : _options$action, + container = options.container, + target = options.target, + text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'. + + if (action !== 'copy' && action !== 'cut') { + throw new Error('Invalid "action" value, use either "copy" or "cut"'); + } // Sets the `target` property using an element that will be have its content copied. + + + if (target !== undefined) { + if (target && _typeof(target) === 'object' && target.nodeType === 1) { + if (action === 'copy' && target.hasAttribute('disabled')) { + throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); + } + + if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) { + throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); + } + } else { + throw new Error('Invalid "target" value, use a valid Element'); + } + } // Define selection strategy based on `text` property. + + + if (text) { + return clipboard_action_copy(text, { + container: container + }); + } // Defines which selection strategy based on `target` property. + + + if (target) { + return action === 'cut' ? clipboard_action_cut(target) : clipboard_action_copy(target, { + container: container + }); + } +}; + +/* harmony default export */ var clipboard_action_default = (ClipboardActionDefault); +;// CONCATENATED MODULE: ./src/clipboard.js +function clipboard_typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return clipboard_typeof(obj); } + function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } - -/** - * Inner class which performs selection from either `text` or `target` - * properties and then executes copy or cut operations. - */ - -var ClipboardAction = /*#__PURE__*/function () { - /** - * @param {Object} options - */ - function ClipboardAction(options) { - _classCallCheck(this, ClipboardAction); - - this.resolveOptions(options); - this.initSelection(); - } - /** - * Defines base properties passed from constructor. - * @param {Object} options - */ - - - _createClass(ClipboardAction, [{ - key: "resolveOptions", - value: function resolveOptions() { - var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - this.action = options.action; - this.container = options.container; - this.emitter = options.emitter; - this.target = options.target; - this.text = options.text; - this.trigger = options.trigger; - this.selectedText = ''; - } - /** - * Decides which selection strategy is going to be applied based - * on the existence of `text` and `target` properties. - */ - - }, { - key: "initSelection", - value: function initSelection() { - if (this.text) { - this.selectFake(); - } else if (this.target) { - this.selectTarget(); - } - } - /** - * Creates a fake textarea element, sets its value from `text` property, - */ - - }, { - key: "createFakeElement", - value: function createFakeElement() { - var isRTL = document.documentElement.getAttribute('dir') === 'rtl'; - this.fakeElem = document.createElement('textarea'); // Prevent zooming on iOS - - this.fakeElem.style.fontSize = '12pt'; // Reset box model - - this.fakeElem.style.border = '0'; - this.fakeElem.style.padding = '0'; - this.fakeElem.style.margin = '0'; // Move element out of screen horizontally - - this.fakeElem.style.position = 'absolute'; - this.fakeElem.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically - - var yPosition = window.pageYOffset || document.documentElement.scrollTop; - this.fakeElem.style.top = "".concat(yPosition, "px"); - this.fakeElem.setAttribute('readonly', ''); - this.fakeElem.value = this.text; - return this.fakeElem; - } - /** - * Get's the value of fakeElem, - * and makes a selection on it. - */ - - }, { - key: "selectFake", - value: function selectFake() { - var _this = this; - - var fakeElem = this.createFakeElement(); - - this.fakeHandlerCallback = function () { - return _this.removeFake(); - }; - - this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true; - this.container.appendChild(fakeElem); - this.selectedText = select_default()(fakeElem); - this.copyText(); - this.removeFake(); - } - /** - * Only removes the fake element after another click event, that way - * a user can hit `Ctrl+C` to copy because selection still exists. - */ - - }, { - key: "removeFake", - value: function removeFake() { - if (this.fakeHandler) { - this.container.removeEventListener('click', this.fakeHandlerCallback); - this.fakeHandler = null; - this.fakeHandlerCallback = null; - } - - if (this.fakeElem) { - this.container.removeChild(this.fakeElem); - this.fakeElem = null; - } - } - /** - * Selects the content from element passed on `target` property. - */ - - }, { - key: "selectTarget", - value: function selectTarget() { - this.selectedText = select_default()(this.target); - this.copyText(); - } - /** - * Executes the copy operation based on the current selection. - */ - - }, { - key: "copyText", - value: function copyText() { - var succeeded; - - try { - succeeded = document.execCommand(this.action); - } catch (err) { - succeeded = false; - } - - this.handleResult(succeeded); - } - /** - * Fires an event based on the copy operation result. - * @param {Boolean} succeeded - */ - - }, { - key: "handleResult", - value: function handleResult(succeeded) { - this.emitter.emit(succeeded ? 'success' : 'error', { - action: this.action, - text: this.selectedText, - trigger: this.trigger, - clearSelection: this.clearSelection.bind(this) - }); - } - /** - * Moves focus away from `target` and back to the trigger, removes current selection. - */ - - }, { - key: "clearSelection", - value: function clearSelection() { - if (this.trigger) { - this.trigger.focus(); - } - - document.activeElement.blur(); - window.getSelection().removeAllRanges(); - } - /** - * Sets the `action` to be performed which can be either 'copy' or 'cut'. - * @param {String} action - */ - - }, { - key: "destroy", - - /** - * Destroy lifecycle. - */ - value: function destroy() { - this.removeFake(); - } - }, { - key: "action", - set: function set() { - var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'copy'; - this._action = action; - - if (this._action !== 'copy' && this._action !== 'cut') { - throw new Error('Invalid "action" value, use either "copy" or "cut"'); - } - } - /** - * Gets the `action` property. - * @return {String} - */ - , - get: function get() { - return this._action; - } - /** - * Sets the `target` property using an element - * that will be have its content copied. - * @param {Element} target - */ - - }, { - key: "target", - set: function set(target) { - if (target !== undefined) { - if (target && _typeof(target) === 'object' && target.nodeType === 1) { - if (this.action === 'copy' && target.hasAttribute('disabled')) { - throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); - } - - if (this.action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) { - throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); - } - - this._target = target; - } else { - throw new Error('Invalid "target" value, use a valid Element'); - } - } - } - /** - * Gets the `target` property. - * @return {String|HTMLElement} - */ - , - get: function get() { - return this._target; - } - }]); - - return ClipboardAction; -}(); - -/* harmony default export */ var clipboard_action = (ClipboardAction); -;// CONCATENATED MODULE: ./src/clipboard.js -function clipboard_typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return clipboard_typeof(obj); } - -function clipboard_classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } - -function clipboard_defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } - -function clipboard_createClass(Constructor, protoProps, staticProps) { if (protoProps) clipboard_defineProperties(Constructor.prototype, protoProps); if (staticProps) clipboard_defineProperties(Constructor, staticProps); return Constructor; } - function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } @@ -312,6 +203,8 @@ function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.g + + /** * Helper function to retrieve attribute value. * @param {String} suffix @@ -345,9 +238,11 @@ var Clipboard = /*#__PURE__*/function (_Emitter) { function Clipboard(trigger, options) { var _this; - clipboard_classCallCheck(this, Clipboard); + _classCallCheck(this, Clipboard); _this = _super.call(this); + _this.ClipboardActionCut = clipboard_action_cut.bind(_assertThisInitialized(_this)); + _this.ClipboardActionCopy = clipboard_action_copy.bind(_assertThisInitialized(_this)); _this.resolveOptions(options); @@ -362,7 +257,7 @@ var Clipboard = /*#__PURE__*/function (_Emitter) { */ - clipboard_createClass(Clipboard, [{ + _createClass(Clipboard, [{ key: "resolveOptions", value: function resolveOptions() { var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -394,18 +289,25 @@ var Clipboard = /*#__PURE__*/function (_Emitter) { key: "onClick", value: function onClick(e) { var trigger = e.delegateTarget || e.currentTarget; - - if (this.clipboardAction) { - this.clipboardAction = null; - } - - this.clipboardAction = new clipboard_action({ + var selectedText = clipboard_action_default({ action: this.action(trigger), - target: this.target(trigger), - text: this.text(trigger), container: this.container, + target: this.target(trigger), + text: this.text(trigger) + }); // Fires an event based on the copy operation result. + + this.emit(selectedText ? 'success' : 'error', { + action: this.action, + text: selectedText, trigger: trigger, - emitter: this + clearSelection: function clearSelection() { + if (trigger) { + trigger.focus(); + } + + document.activeElement.blur(); + window.getSelection().removeAllRanges(); + } }); } /** @@ -432,12 +334,6 @@ var Clipboard = /*#__PURE__*/function (_Emitter) { return document.querySelector(selector); } } - /** - * Returns the support of the given action, or all actions if no action is - * given. - * @param {String} [action] - */ - }, { key: "defaultText", @@ -456,13 +352,27 @@ var Clipboard = /*#__PURE__*/function (_Emitter) { key: "destroy", value: function destroy() { this.listener.destroy(); - - if (this.clipboardAction) { - this.clipboardAction.destroy(); - this.clipboardAction = null; - } } }], [{ + key: "copy", + value: function copy(target) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { + container: document.body + }; + return clipboard_action_copy(target, options); + } + }, { + key: "cut", + value: function cut(target) { + return clipboard_action_cut(target); + } + /** + * Returns the support of the given action, or all actions if no action is + * given. + * @param {String} [action] + */ + + }, { key: "isSupported", value: function isSupported() { var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut']; @@ -948,7 +858,7 @@ module.exports.TinyEmitter = E; /******/ // module exports must be returned from runtime so entry inlining is disabled /******/ // startup /******/ // Load entry module and return exports -/******/ return __webpack_require__(134); +/******/ return __webpack_require__(747); /******/ })() .default; }); \ No newline at end of file diff --git a/dist/clipboard.min.js b/dist/clipboard.min.js index 95f55d7..54b3c46 100644 --- a/dist/clipboard.min.js +++ b/dist/clipboard.min.js @@ -4,4 +4,4 @@ * * Licensed MIT © Zeno Rocha */ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={134:function(t,e,n){"use strict";n.d(e,{default:function(){return r}});var e=n(279),i=n.n(e),e=n(370),a=n.n(e),e=n(817),o=n.n(e);function c(t){return(c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function u(t,e){for(var n=0;n { + let selectedText = ''; + if (typeof target === 'string') { + const fakeElement = createFakeElement(target); + options.container.appendChild(fakeElement); + selectedText = select(fakeElement); + command('copy'); + fakeElement.remove(); + } else { + selectedText = select(target); + command('copy'); + } + return selectedText; +}; + +export default ClipboardActionCopy; diff --git a/src/actions/cut.js b/src/actions/cut.js new file mode 100644 index 0000000..537ce48 --- /dev/null +++ b/src/actions/cut.js @@ -0,0 +1,15 @@ +import select from 'select'; +import command from '../common/command'; + +/** + * Cut action wrapper. + * @param {String|HTMLElement} target + * @return {String} + */ +const ClipboardActionCut = (target) => { + const selectedText = select(target); + command('cut'); + return selectedText; +}; + +export default ClipboardActionCut; diff --git a/src/actions/default.js b/src/actions/default.js new file mode 100644 index 0000000..d1af8e5 --- /dev/null +++ b/src/actions/default.js @@ -0,0 +1,53 @@ +import ClipboardActionCut from './cut'; +import ClipboardActionCopy from './copy'; + +/** + * Inner function which performs selection from either `text` or `target` + * properties and then executes copy or cut operations. + * @param {Object} options + */ +const ClipboardActionDefault = (options = {}) => { + // Defines base properties passed from constructor. + const { action = 'copy', container, target, text } = options; + + // Sets the `action` to be performed which can be either 'copy' or 'cut'. + if (action !== 'copy' && action !== 'cut') { + throw new Error('Invalid "action" value, use either "copy" or "cut"'); + } + + // Sets the `target` property using an element that will be have its content copied. + if (target !== undefined) { + if (target && typeof target === 'object' && target.nodeType === 1) { + if (action === 'copy' && target.hasAttribute('disabled')) { + throw new Error( + 'Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute' + ); + } + + if ( + action === 'cut' && + (target.hasAttribute('readonly') || target.hasAttribute('disabled')) + ) { + throw new Error( + 'Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes' + ); + } + } else { + throw new Error('Invalid "target" value, use a valid Element'); + } + } + + // Define selection strategy based on `text` property. + if (text) { + return ClipboardActionCopy(text, { container }); + } + + // Defines which selection strategy based on `target` property. + if (target) { + return action === 'cut' + ? ClipboardActionCut(target) + : ClipboardActionCopy(target, { container }); + } +}; + +export default ClipboardActionDefault; diff --git a/src/clipboard-action.js b/src/clipboard-action.js deleted file mode 100644 index ab6a5f6..0000000 --- a/src/clipboard-action.js +++ /dev/null @@ -1,221 +0,0 @@ -import select from 'select'; - -/** - * Inner class which performs selection from either `text` or `target` - * properties and then executes copy or cut operations. - */ -class ClipboardAction { - /** - * @param {Object} options - */ - constructor(options) { - this.resolveOptions(options); - this.initSelection(); - } - - /** - * Defines base properties passed from constructor. - * @param {Object} options - */ - resolveOptions(options = {}) { - this.action = options.action; - this.container = options.container; - this.emitter = options.emitter; - this.target = options.target; - this.text = options.text; - this.trigger = options.trigger; - - this.selectedText = ''; - } - - /** - * Decides which selection strategy is going to be applied based - * on the existence of `text` and `target` properties. - */ - initSelection() { - if (this.text) { - this.selectFake(); - } else if (this.target) { - this.selectTarget(); - } - } - - /** - * Creates a fake textarea element, sets its value from `text` property, - */ - createFakeElement() { - const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; - - this.fakeElem = document.createElement('textarea'); - // Prevent zooming on iOS - this.fakeElem.style.fontSize = '12pt'; - // Reset box model - this.fakeElem.style.border = '0'; - this.fakeElem.style.padding = '0'; - this.fakeElem.style.margin = '0'; - // Move element out of screen horizontally - this.fakeElem.style.position = 'absolute'; - this.fakeElem.style[isRTL ? 'right' : 'left'] = '-9999px'; - // Move element to the same position vertically - let yPosition = window.pageYOffset || document.documentElement.scrollTop; - this.fakeElem.style.top = `${yPosition}px`; - - this.fakeElem.setAttribute('readonly', ''); - this.fakeElem.value = this.text; - - return this.fakeElem; - } - - /** - * Get's the value of fakeElem, - * and makes a selection on it. - */ - selectFake() { - const fakeElem = this.createFakeElement(); - - this.fakeHandlerCallback = () => this.removeFake(); - - this.fakeHandler = - this.container.addEventListener('click', this.fakeHandlerCallback) || - true; - - this.container.appendChild(fakeElem); - - this.selectedText = select(fakeElem); - - this.copyText(); - - this.removeFake(); - } - - /** - * Only removes the fake element after another click event, that way - * a user can hit `Ctrl+C` to copy because selection still exists. - */ - removeFake() { - if (this.fakeHandler) { - this.container.removeEventListener('click', this.fakeHandlerCallback); - this.fakeHandler = null; - this.fakeHandlerCallback = null; - } - - if (this.fakeElem) { - this.container.removeChild(this.fakeElem); - this.fakeElem = null; - } - } - - /** - * Selects the content from element passed on `target` property. - */ - selectTarget() { - this.selectedText = select(this.target); - this.copyText(); - } - - /** - * Executes the copy operation based on the current selection. - */ - copyText() { - let succeeded; - - try { - succeeded = document.execCommand(this.action); - } catch (err) { - succeeded = false; - } - - this.handleResult(succeeded); - } - - /** - * Fires an event based on the copy operation result. - * @param {Boolean} succeeded - */ - handleResult(succeeded) { - this.emitter.emit(succeeded ? 'success' : 'error', { - action: this.action, - text: this.selectedText, - trigger: this.trigger, - clearSelection: this.clearSelection.bind(this), - }); - } - - /** - * Moves focus away from `target` and back to the trigger, removes current selection. - */ - clearSelection() { - if (this.trigger) { - this.trigger.focus(); - } - document.activeElement.blur(); - window.getSelection().removeAllRanges(); - } - - /** - * Sets the `action` to be performed which can be either 'copy' or 'cut'. - * @param {String} action - */ - set action(action = 'copy') { - this._action = action; - - if (this._action !== 'copy' && this._action !== 'cut') { - throw new Error('Invalid "action" value, use either "copy" or "cut"'); - } - } - - /** - * Gets the `action` property. - * @return {String} - */ - get action() { - return this._action; - } - - /** - * Sets the `target` property using an element - * that will be have its content copied. - * @param {Element} target - */ - set target(target) { - if (target !== undefined) { - if (target && typeof target === 'object' && target.nodeType === 1) { - if (this.action === 'copy' && target.hasAttribute('disabled')) { - throw new Error( - 'Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute' - ); - } - - if ( - this.action === 'cut' && - (target.hasAttribute('readonly') || target.hasAttribute('disabled')) - ) { - throw new Error( - 'Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes' - ); - } - - this._target = target; - } else { - throw new Error('Invalid "target" value, use a valid Element'); - } - } - } - - /** - * Gets the `target` property. - * @return {String|HTMLElement} - */ - get target() { - return this._target; - } - - /** - * Destroy lifecycle. - */ - destroy() { - this.removeFake(); - } -} - -export default ClipboardAction; diff --git a/src/clipboard.d.ts b/src/clipboard.d.ts index b45dcc6..98b959f 100644 --- a/src/clipboard.d.ts +++ b/src/clipboard.d.ts @@ -2,11 +2,7 @@ type Action = 'cut' | 'copy'; type Response = 'success' | 'error'; - -type Options = { - text?: string; - action?: Action; - target?: Element; +type CopyActionOptions = { container?: Element; }; @@ -38,6 +34,17 @@ declare class ClipboardJS { * Checks if clipboard.js is supported */ static isSupported(): boolean; + + + /** + * Fires a copy action + */ + static copy(target: string | Element, options: CopyActionOptions): string; + + /** + * Fires a cut action + */ + static cut(target: string | Element): string; } declare namespace ClipboardJS { diff --git a/src/clipboard.js b/src/clipboard.js index 6e26253..ffe370b 100644 --- a/src/clipboard.js +++ b/src/clipboard.js @@ -1,6 +1,8 @@ import Emitter from 'tiny-emitter'; import listen from 'good-listener'; -import ClipboardAction from './clipboard-action'; +import ClipboardActionDefault from './actions/default'; +import ClipboardActionCut from './actions/cut'; +import ClipboardActionCopy from './actions/copy'; /** * Helper function to retrieve attribute value. @@ -67,18 +69,25 @@ class Clipboard extends Emitter { */ onClick(e) { const trigger = e.delegateTarget || e.currentTarget; - - if (this.clipboardAction) { - this.clipboardAction = null; - } - - this.clipboardAction = new ClipboardAction({ + const selectedText = ClipboardActionDefault({ action: this.action(trigger), + container: this.container, target: this.target(trigger), text: this.text(trigger), - container: this.container, + }); + + // Fires an event based on the copy operation result. + this.emit(selectedText ? 'success' : 'error', { + action: this.action, + text: selectedText, trigger, - emitter: this, + clearSelection() { + if (trigger) { + trigger.focus(); + } + document.activeElement.blur(); + window.getSelection().removeAllRanges(); + }, }); } @@ -102,6 +111,25 @@ class Clipboard extends Emitter { } } + /** + * Allow fire programmatically a copy action + * @param {String|HTMLElement} target + * @param {Object} options + * @returns Text copied. + */ + static copy(target, options = { container: document.body }) { + return ClipboardActionCopy(target, options); + } + + /** + * Allow fire programmatically a cut action + * @param {String|HTMLElement} target + * @returns Text cutted. + */ + static cut(target) { + return ClipboardActionCut(target); + } + /** * Returns the support of the given action, or all actions if no action is * given. @@ -131,11 +159,6 @@ class Clipboard extends Emitter { */ destroy() { this.listener.destroy(); - - if (this.clipboardAction) { - this.clipboardAction.destroy(); - this.clipboardAction = null; - } } } diff --git a/src/clipboard.test-d.ts b/src/clipboard.test-d.ts index d09b4eb..dceb32e 100644 --- a/src/clipboard.test-d.ts +++ b/src/clipboard.test-d.ts @@ -1,4 +1,4 @@ import { expectType } from 'tsd'; -import Clipboard from './clipboard'; +import * as Clipboard from './clipboard'; expectType(new Clipboard('.btn')); diff --git a/src/common/command.js b/src/common/command.js new file mode 100644 index 0000000..5980cb4 --- /dev/null +++ b/src/common/command.js @@ -0,0 +1,12 @@ +/** + * Executes a given operation type. + * @param {String} type + * @return {Boolean} + */ +export default function command(type) { + try { + return document.execCommand(type); + } catch (err) { + return false; + } +} diff --git a/src/common/create-fake-element.js b/src/common/create-fake-element.js new file mode 100644 index 0000000..aa8db8d --- /dev/null +++ b/src/common/create-fake-element.js @@ -0,0 +1,26 @@ +/** + * Creates a fake textarea element with a value. + * @param {String} value + * @return {HTMLElement} + */ +export default function createFakeElement(value) { + const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; + const fakeElement = document.createElement('textarea'); + // Prevent zooming on iOS + fakeElement.style.fontSize = '12pt'; + // Reset box model + fakeElement.style.border = '0'; + fakeElement.style.padding = '0'; + fakeElement.style.margin = '0'; + // Move element out of screen horizontally + fakeElement.style.position = 'absolute'; + fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; + // Move element to the same position vertically + let yPosition = window.pageYOffset || document.documentElement.scrollTop; + fakeElement.style.top = `${yPosition}px`; + + fakeElement.setAttribute('readonly', ''); + fakeElement.value = value; + + return fakeElement; +} diff --git a/test/actions/copy.js b/test/actions/copy.js new file mode 100644 index 0000000..201c1d6 --- /dev/null +++ b/test/actions/copy.js @@ -0,0 +1,55 @@ +import ClipboardActionCopy from '../../src/actions/copy'; + +describe('ClipboardActionCopy', () => { + before(() => { + global.input = document.createElement('input'); + global.input.setAttribute('id', 'input'); + global.input.setAttribute('value', 'abc'); + document.body.appendChild(global.input); + + global.paragraph = document.createElement('p'); + global.paragraph.setAttribute('id', 'paragraph'); + global.paragraph.textContent = 'abc'; + document.body.appendChild(global.paragraph); + }); + + after(() => { + document.body.innerHTML = ''; + }); + + describe('#selectText', () => { + it('should select its value based on input target', () => { + const selectedText = ClipboardActionCopy( + document.querySelector('#input'), + { + container: document.body, + } + ); + + assert.equal(selectedText, document.querySelector('#input').value); + }); + + it('should select its value based on element target', () => { + const selectedText = ClipboardActionCopy( + document.querySelector('#paragraph'), + { + container: document.body, + } + ); + + assert.equal( + selectedText, + document.querySelector('#paragraph').textContent + ); + }); + + it('should select its value based on text', () => { + const text = 'abc'; + const selectedText = ClipboardActionCopy(text, { + container: document.body, + }); + + assert.equal(selectedText, text); + }); + }); +}); diff --git a/test/actions/cut.js b/test/actions/cut.js new file mode 100644 index 0000000..44c1ac5 --- /dev/null +++ b/test/actions/cut.js @@ -0,0 +1,32 @@ +import ClipboardActionCut from '../../src/actions/cut'; + +describe('ClipboardActionCut', () => { + before(() => { + global.input = document.createElement('input'); + global.input.setAttribute('id', 'input'); + global.input.setAttribute('value', 'abc'); + document.body.appendChild(global.input); + + global.paragraph = document.createElement('p'); + global.paragraph.setAttribute('id', 'paragraph'); + global.paragraph.textContent = 'abc'; + document.body.appendChild(global.paragraph); + }); + + after(() => { + document.body.innerHTML = ''; + }); + + describe('#selectText', () => { + it('should select its value', () => { + const selectedText = ClipboardActionCut( + document.querySelector('#input'), + { + container: document.body, + } + ); + + assert.equal(selectedText, document.querySelector('#input').value); + }); + }); +}); diff --git a/test/actions/default.js b/test/actions/default.js new file mode 100644 index 0000000..8fb99cd --- /dev/null +++ b/test/actions/default.js @@ -0,0 +1,80 @@ +import ClipboardActionDefault from '../../src/actions/default'; + +describe('ClipboardActionDefault', () => { + before(() => { + global.input = document.createElement('input'); + global.input.setAttribute('id', 'input'); + global.input.setAttribute('value', 'abc'); + document.body.appendChild(global.input); + + global.paragraph = document.createElement('p'); + global.paragraph.setAttribute('id', 'paragraph'); + global.paragraph.textContent = 'abc'; + document.body.appendChild(global.paragraph); + }); + + after(() => { + document.body.innerHTML = ''; + }); + + describe('#resolveOptions', () => { + it('should set base properties', () => { + const selectedText = ClipboardActionDefault({ + container: document.body, + text: 'foo', + }); + + assert.equal(selectedText, 'foo'); + }); + }); + + describe('#set action', () => { + it('should throw an error since "action" is invalid', (done) => { + try { + let clip = ClipboardActionDefault({ + text: 'foo', + action: 'paste', + }); + } catch (e) { + assert.equal( + e.message, + 'Invalid "action" value, use either "copy" or "cut"' + ); + done(); + } + }); + }); + + describe('#set target', () => { + it('should throw an error since "target" do not match any element', (done) => { + try { + let clip = ClipboardActionDefault({ + target: document.querySelector('#foo'), + }); + } catch (e) { + assert.equal(e.message, 'Invalid "target" value, use a valid Element'); + done(); + } + }); + }); + + describe('#selectedText', () => { + it('should select text from editable element', () => { + const selectedText = ClipboardActionDefault({ + container: document.body, + target: document.querySelector('#input'), + }); + + assert.equal(selectedText, 'abc'); + }); + + it('should select text from non-editable element', () => { + const selectedText = ClipboardActionDefault({ + container: document.body, + target: document.querySelector('#paragraph'), + }); + + assert.equal(selectedText, 'abc'); + }); + }); +}); diff --git a/test/clipboard-action.js b/test/clipboard-action.js deleted file mode 100644 index b589b4c..0000000 --- a/test/clipboard-action.js +++ /dev/null @@ -1,248 +0,0 @@ -import Emitter from 'tiny-emitter'; -import ClipboardAction from '../src/clipboard-action'; - -describe('ClipboardAction', () => { - before(() => { - global.input = document.createElement('input'); - global.input.setAttribute('id', 'input'); - global.input.setAttribute('value', 'abc'); - document.body.appendChild(global.input); - - global.paragraph = document.createElement('p'); - global.paragraph.setAttribute('id', 'paragraph'); - global.paragraph.textContent = 'abc'; - document.body.appendChild(global.paragraph); - }); - - after(() => { - document.body.innerHTML = ''; - }); - - describe('#resolveOptions', () => { - it('should set base properties', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - text: 'foo', - }); - - assert.property(clip, 'action'); - assert.property(clip, 'container'); - assert.property(clip, 'emitter'); - assert.property(clip, 'target'); - assert.property(clip, 'text'); - assert.property(clip, 'trigger'); - assert.property(clip, 'selectedText'); - }); - }); - - describe('#initSelection', () => { - it('should set the position right style property', (done) => { - // Set document direction - document.documentElement.setAttribute('dir', 'rtl'); - - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - text: 'foo', - }); - - const el = clip.createFakeElement(); - - assert.equal(el.style.right, '-9999px'); - done(); - }); - }); - - describe('#set action', () => { - it('should throw an error since "action" is invalid', (done) => { - try { - let clip = new ClipboardAction({ - text: 'foo', - action: 'paste', - }); - } catch (e) { - assert.equal( - e.message, - 'Invalid "action" value, use either "copy" or "cut"' - ); - done(); - } - }); - }); - - describe('#set target', () => { - it('should throw an error since "target" do not match any element', (done) => { - try { - let clip = new ClipboardAction({ - target: document.querySelector('#foo'), - }); - } catch (e) { - assert.equal(e.message, 'Invalid "target" value, use a valid Element'); - done(); - } - }); - }); - - describe('#selectText', () => { - it('should create a fake element and select its value', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - text: 'blah', - }); - - const el = clip.createFakeElement(); - - assert.equal(clip.selectedText, el.value); - }); - }); - - describe('#removeFake', () => { - it('should remove a temporary fake element', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - text: 'blah', - }); - - clip.removeFake(); - - assert.equal(clip.fakeElem, null); - }); - }); - - describe('#selectTarget', () => { - it('should select text from editable element', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - target: document.querySelector('#input'), - }); - - assert.equal(clip.selectedText, clip.target.value); - }); - - it('should select text from non-editable element', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - target: document.querySelector('#paragraph'), - }); - - assert.equal(clip.selectedText, clip.target.textContent); - }); - }); - - describe('#copyText', () => { - before(() => { - global.stub = sinon.stub(document, 'execCommand'); - }); - - after(() => { - global.stub.restore(); - }); - - it('should fire a success event on browsers that support copy command', (done) => { - global.stub.returns(true); - - let emitter = new Emitter(); - - emitter.on('success', () => { - done(); - }); - - let clip = new ClipboardAction({ - emitter, - target: document.querySelector('#input'), - }); - }); - - it('should fire an error event on browsers that support copy command', (done) => { - global.stub.returns(false); - - let emitter = new Emitter(); - - emitter.on('error', () => { - done(); - }); - - let clip = new ClipboardAction({ - emitter, - target: document.querySelector('#input'), - }); - }); - }); - - describe('#handleResult', () => { - it('should fire a success event with certain properties', (done) => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - target: document.querySelector('#input'), - }); - - clip.emitter.on('success', (e) => { - assert.property(e, 'action'); - assert.property(e, 'text'); - assert.property(e, 'trigger'); - assert.property(e, 'clearSelection'); - - done(); - }); - - clip.handleResult(true); - }); - - it('should fire a error event with certain properties', (done) => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - target: document.querySelector('#input'), - }); - - clip.emitter.on('error', (e) => { - assert.property(e, 'action'); - assert.property(e, 'trigger'); - assert.property(e, 'clearSelection'); - - done(); - }); - - clip.handleResult(false); - }); - }); - - describe('#clearSelection', () => { - it('should remove focus from target and text selection', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - target: document.querySelector('#input'), - }); - - clip.clearSelection(); - - let selectedElem = document.activeElement; - let selectedText = window.getSelection().toString(); - - assert.equal(selectedElem, document.body); - assert.equal(selectedText, ''); - }); - }); - - describe('#destroy', () => { - it('should destroy an existing fake element', () => { - let clip = new ClipboardAction({ - emitter: new Emitter(), - container: document.body, - text: 'blah', - }); - - clip.selectFake(); - clip.destroy(); - - assert.equal(clip.fakeElem, null); - }); - }); -}); diff --git a/test/clipboard.js b/test/clipboard.js index 0a59e20..b5059c6 100644 --- a/test/clipboard.js +++ b/test/clipboard.js @@ -1,5 +1,4 @@ import Clipboard from '../src/clipboard'; -import ClipboardAction from '../src/clipboard-action'; describe('Clipboard', () => { before(() => { @@ -75,22 +74,28 @@ describe('Clipboard', () => { }); describe('#onClick', () => { - it('should create a new instance of ClipboardAction', () => { + it('should init when called', (done) => { let clipboard = new Clipboard('.btn'); + clipboard.on('success', () => { + done(); + }); + clipboard.onClick(global.event); - assert.instanceOf(clipboard.clipboardAction, ClipboardAction); }); - it("should use an event's currentTarget when not equal to target", () => { + it("should use an event's currentTarget when not equal to target", (done) => { let clipboard = new Clipboard('.btn'); let bubbledEvent = { target: global.span, currentTarget: global.button, }; + clipboard.on('success', () => { + done(); + }); + clipboard.onClick(bubbledEvent); - assert.instanceOf(clipboard.clipboardAction, ClipboardAction); }); it('should throw an exception when target is invalid', (done) => { @@ -120,8 +125,24 @@ describe('Clipboard', () => { }); }); + describe('#static copy', () => { + it('should copy in an programatic way based on text', () => { + assert.equal(Clipboard.copy('lorem'), 'lorem'); + }); + + it('should copy in an programatic way based on target', () => { + assert.equal(Clipboard.copy(document.querySelector('span')), 'bar'); + }); + }); + + describe('#static cut', () => { + it('should cut in an programatic way based on text', () => { + assert.equal(Clipboard.cut(document.querySelector('span')), 'bar'); + }); + }); + describe('#destroy', () => { - it('should destroy an existing instance of ClipboardAction', () => { + it('should destroy an existing instance of ClipboardActionDefault', () => { let clipboard = new Clipboard('.btn'); clipboard.onClick(global.event); @@ -130,4 +151,41 @@ describe('Clipboard', () => { assert.equal(clipboard.clipboardAction, null); }); }); + + describe('#events', () => { + it('should fire a success event with certain properties', (done) => { + let clipboard = new Clipboard('.btn'); + + clipboard.on('success', (e) => { + assert.property(e, 'action'); + assert.property(e, 'text'); + assert.property(e, 'trigger'); + assert.property(e, 'clearSelection'); + + done(); + }); + + clipboard.onClick(global.event); + }); + }); + + describe('#clearSelection', () => { + it('should remove focus from target and text selection', (done) => { + let clipboard = new Clipboard('.btn'); + + clipboard.on('success', (e) => { + let selectedElem = document.activeElement; + let selectedText = window.getSelection().toString(); + + e.clearSelection(); + + assert.equal(selectedElem, document.body); + assert.equal(selectedText, ''); + + done(); + }); + + clipboard.onClick(global.event); + }); + }); }); diff --git a/test/common/command.js b/test/common/command.js new file mode 100644 index 0000000..474d7c1 --- /dev/null +++ b/test/common/command.js @@ -0,0 +1,49 @@ +import select from 'select'; +import command from '../../src/common/command'; + +describe('#command', () => { + before(() => { + global.stub = sinon.stub(document, 'execCommand'); + global.input = document.createElement('input'); + global.input.setAttribute('id', 'input'); + global.input.setAttribute('value', 'abc'); + document.body.appendChild(global.input); + }); + + after(() => { + global.stub.restore(); + document.body.innerHTML = ''; + }); + + it('should execute cut', (done) => { + global.stub.returns(true); + select(document.querySelector('#input')); + + assert.isTrue(command('cut')); + done(); + }); + + it('should execute copy', (done) => { + global.stub.returns(true); + select(document.querySelector('#input')); + + assert.isTrue(command('copy')); + done(); + }); + + it('should not execute copy', (done) => { + global.stub.returns(false); + select(document.querySelector('#input')); + + assert.isFalse(command('copy')); + done(); + }); + + it('should not execute cut', (done) => { + global.stub.returns(false); + select(document.querySelector('#input')); + + assert.isFalse(command('cut')); + done(); + }); +}); diff --git a/test/common/create-fake-element.js b/test/common/create-fake-element.js new file mode 100644 index 0000000..98934a8 --- /dev/null +++ b/test/common/create-fake-element.js @@ -0,0 +1,13 @@ +import createFakeElement from '../../src/common/create-fake-element'; + +describe('createFakeElement', () => { + it('should define a fake element and set the position right style property', (done) => { + // Set document direction + document.documentElement.setAttribute('dir', 'rtl'); + + const el = createFakeElement(document.body); + + assert.equal(el.style.right, '-9999px'); + done(); + }); +});