├── .travis.yml ├── .gitignore ├── .babelrc ├── package.js ├── bower.json ├── .github ├── issue_template.md └── stale.yml ├── .editorconfig ├── composer.json ├── demo ├── target-div.html ├── function-text.html ├── target-textarea.html ├── target-input.html ├── constructor-node.html ├── function-target.html ├── constructor-selector.html └── constructor-nodelist.html ├── karma.conf.js ├── LICENSE ├── package.json ├── webpack.config.js ├── contributing.md ├── src ├── clipboard.js └── clipboard-action.js ├── test ├── clipboard.js └── clipboard-action.js ├── readme.md └── dist ├── clipboard.min.js └── clipboard.js /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - stable 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | npm-debug.log 3 | bower_components 4 | node_modules 5 | yarn-error.log 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "uglify": true 8 | }, 9 | "modules": false 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | // Package metadata for Meteor.js. 2 | 3 | Package.describe({ 4 | name: "zenorocha:clipboard", 5 | summary: "Modern copy to clipboard. No Flash. Just 3kb.", 6 | version: "2.0.6", 7 | git: "https://github.com/zenorocha/clipboard.js" 8 | }); 9 | 10 | Package.onUse(function(api) { 11 | api.addFiles("dist/clipboard.js", "client"); 12 | }); 13 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clipboard", 3 | "version": "2.0.6", 4 | "description": "Modern copy to clipboard. No Flash. Just 3kb", 5 | "license": "MIT", 6 | "main": "dist/clipboard.js", 7 | "ignore": [ 8 | "/.*/", 9 | "/demo/", 10 | "/test/", 11 | "/.*", 12 | "/bower.json", 13 | "/karma.conf.js", 14 | "/src", 15 | "/lib" 16 | ], 17 | "keywords": [ 18 | "clipboard", 19 | "copy", 20 | "cut" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Minimal example 2 | 3 | > Fork this [JSFiddle](https://jsfiddle.net/zenorocha/5kk0eysw/) and reproduce your issue. 4 | 5 | ### Expected behaviour 6 | 7 | I thought that by going to the page '...' and pressing the button '...' then '...' would happen. 8 | 9 | ### Actual behaviour 10 | 11 | Instead of '...', what I saw was that '...' happened instead. 12 | 13 | ### Browsers affected 14 | 15 | I tested on all major browsers and only IE 11 does not work. 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | 21 | [{package.json,bower.json}] 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenorocha/clipboardjs", 3 | "description": "Modern copy to clipboard. No Flash. Just 3kb gzipped https://clipboardjs.com", 4 | "type": "component", 5 | "homepage": "https://clipboardjs.com/", 6 | "authors": [ 7 | { 8 | "name": "Zeno Rocha", 9 | "homepage": "http://zenorocha.com/" 10 | } 11 | ], 12 | "require": { 13 | "oomphinc/composer-installers-extender": "*" 14 | }, 15 | "extra": { 16 | "component": { 17 | "scripts": [ 18 | "dist/clipboard.js" 19 | ], 20 | "files": [ 21 | "dist/clipboard.min.js" 22 | ] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: stale 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /demo/target-div.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | target-div 6 | 7 | 8 | 9 | 10 |
hello
11 | 12 | 13 | 14 | 15 | 16 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | 3 | module.exports = function (karma) { 4 | karma.set({ 5 | plugins: ['karma-webpack', 'karma-chai', 'karma-sinon', 'karma-mocha', 'karma-chrome-launcher'], 6 | 7 | frameworks: ['chai', 'sinon', 'mocha'], 8 | 9 | files: [ 10 | 'src/**/*.js', 11 | 'test/**/*.js', 12 | ], 13 | 14 | preprocessors: { 15 | 'src/**/*.js': ['webpack'], 16 | 'test/**/*.js': ['webpack'] 17 | }, 18 | 19 | webpack: { 20 | module: webpackConfig.module, 21 | plugins: webpackConfig.plugins 22 | }, 23 | 24 | webpackMiddleware: { 25 | stats: 'errors-only' 26 | }, 27 | 28 | browsers: ['ChromeHeadless'] 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /demo/function-text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | function-text 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /demo/target-textarea.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | target-textarea 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /demo/target-input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | target-input 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /demo/constructor-node.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | constructor-node 6 | 7 | 8 | 9 | 10 |
11 | Copy 12 |
13 | 14 | 15 | 16 | 17 | 18 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /demo/function-target.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | function-target 6 | 7 | 8 | 9 | 10 | 11 |
hello
12 | 13 | 14 | 15 | 16 | 17 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /demo/constructor-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | constructor-selector 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /demo/constructor-nodelist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | constructor-nodelist 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Zeno Rocha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clipboard", 3 | "version": "2.0.6", 4 | "description": "Modern copy to clipboard. No Flash. Just 2kb", 5 | "repository": "zenorocha/clipboard.js", 6 | "license": "MIT", 7 | "main": "dist/clipboard.js", 8 | "keywords": [ 9 | "clipboard", 10 | "copy", 11 | "cut" 12 | ], 13 | "dependencies": { 14 | "good-listener": "^1.2.2", 15 | "select": "^1.1.2", 16 | "tiny-emitter": "^2.0.0" 17 | }, 18 | "devDependencies": { 19 | "babel-core": "^6.26.0", 20 | "babel-loader": "^7.1.4", 21 | "babel-preset-env": "^1.7.0", 22 | "chai": "^4.2.0", 23 | "cross-env": "^5.2.0", 24 | "karma": "^3.1.1", 25 | "karma-chai": "^0.1.0", 26 | "karma-mocha": "^1.2.0", 27 | "karma-chrome-launcher": "^2.2.0", 28 | "karma-sinon": "^1.0.4", 29 | "karma-webpack": "^3.0.5", 30 | "mocha": "^5.2.0", 31 | "sinon": "^7.1.1", 32 | "uglifyjs-webpack-plugin": "^2.0.1", 33 | "webpack": "^4.5.0", 34 | "webpack-cli": "^3.1.2" 35 | }, 36 | "scripts": { 37 | "build": "npm run build-debug && npm run build-min", 38 | "build-debug": "webpack", 39 | "build-min": "cross-env NODE_ENV=production webpack", 40 | "build-watch": "webpack --watch", 41 | "test": "karma start --single-run", 42 | "prepublish": "npm run build" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json'); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 5 | 6 | const production = process.env.NODE_ENV === 'production' || false; 7 | 8 | const banner = `clipboard.js v${pkg.version} 9 | https://clipboardjs.com/ 10 | 11 | Licensed MIT © Zeno Rocha`; 12 | 13 | module.exports = { 14 | entry: './src/clipboard.js', 15 | mode: 'production', 16 | output: { 17 | filename: production ? 'clipboard.min.js' : 'clipboard.js', 18 | path: path.resolve(__dirname, 'dist'), 19 | library: 'ClipboardJS', 20 | globalObject: 'this', 21 | libraryExport: 'default', 22 | libraryTarget: 'umd' 23 | }, 24 | module: { 25 | rules: [ 26 | {test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader'} 27 | ] 28 | }, 29 | optimization: { 30 | minimize: production, 31 | minimizer: [ 32 | new UglifyJSPlugin({ 33 | parallel: require('os').cpus().length, 34 | uglifyOptions: { 35 | ie8: false, 36 | keep_fnames: false, 37 | output: { 38 | beautify: false, 39 | comments: (node, {value, type}) => type == 'comment2' && value.startsWith('!') 40 | } 41 | } 42 | }) 43 | ] 44 | }, 45 | plugins: [new webpack.BannerPlugin({ banner })] 46 | }; 47 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing guide 2 | 3 | Want to contribute to Clipboard.js? Awesome! 4 | There are many ways you can contribute, see below. 5 | 6 | ## Opening issues 7 | 8 | Open an issue to report bugs or to propose new features. 9 | 10 | - Reporting bugs: describe the bug as clearly as you can, including steps to reproduce, what happened and what you were expecting to happen. Also include browser version, OS and other related software's (npm, Node.js, etc) versions when applicable. 11 | 12 | - Proposing features: explain the proposed feature, what it should do, why it is useful, how users should use it. Give us as much info as possible so it will be easier to discuss, access and implement the proposed feature. When you're unsure about a certain aspect of the feature, feel free to leave it open for others to discuss and find an appropriate solution. 13 | 14 | ## Proposing pull requests 15 | 16 | Pull requests are very welcome. Note that if you are going to propose drastic changes, be sure to open an issue for discussion first, to make sure that your PR will be accepted before you spend effort coding it. 17 | 18 | Fork the Clipboard.js repository, clone it locally and create a branch for your proposed bug fix or new feature. Avoid working directly on the master branch. 19 | 20 | Implement your bug fix or feature, write tests to cover it and make sure all tests are passing (run a final `npm test` to make sure everything is correct). Then commit your changes, push your bug fix/feature branch to the origin (your forked repo) and open a pull request to the upstream (the repository you originally forked)'s master branch. 21 | 22 | ## Documentation 23 | 24 | Documentation is extremely important and takes a fair deal of time and effort to write and keep updated. Please submit any and all improvements you can make to the repository's docs. 25 | 26 | ## Known issues 27 | If you're using npm@3 you'll probably face some issues related to peerDependencies. 28 | https://github.com/npm/npm/issues/9204 29 | -------------------------------------------------------------------------------- /src/clipboard.js: -------------------------------------------------------------------------------- 1 | import ClipboardAction from './clipboard-action'; 2 | import Emitter from 'tiny-emitter'; 3 | import listen from 'good-listener'; 4 | 5 | /** 6 | * Base class which takes one or more elements, adds event listeners to them, 7 | * and instantiates a new `ClipboardAction` on each click. 8 | */ 9 | class Clipboard extends Emitter { 10 | /** 11 | * @param {String|HTMLElement|HTMLCollection|NodeList} trigger 12 | * @param {Object} options 13 | */ 14 | constructor(trigger, options) { 15 | super(); 16 | 17 | this.resolveOptions(options); 18 | this.listenClick(trigger); 19 | } 20 | 21 | /** 22 | * Defines if attributes would be resolved using internal setter functions 23 | * or custom functions that were passed in the constructor. 24 | * @param {Object} options 25 | */ 26 | resolveOptions(options = {}) { 27 | this.action = (typeof options.action === 'function') ? options.action : this.defaultAction; 28 | this.target = (typeof options.target === 'function') ? options.target : this.defaultTarget; 29 | this.text = (typeof options.text === 'function') ? options.text : this.defaultText; 30 | this.container = (typeof options.container === 'object') ? options.container : document.body; 31 | } 32 | 33 | /** 34 | * Adds a click event listener to the passed trigger. 35 | * @param {String|HTMLElement|HTMLCollection|NodeList} trigger 36 | */ 37 | listenClick(trigger) { 38 | this.listener = listen(trigger, 'click', (e) => this.onClick(e)); 39 | } 40 | 41 | /** 42 | * Defines a new `ClipboardAction` on each click event. 43 | * @param {Event} e 44 | */ 45 | onClick(e) { 46 | const trigger = e.delegateTarget || e.currentTarget; 47 | 48 | if (this.clipboardAction) { 49 | this.clipboardAction = null; 50 | } 51 | 52 | this.clipboardAction = new ClipboardAction({ 53 | action : this.action(trigger), 54 | target : this.target(trigger), 55 | text : this.text(trigger), 56 | container : this.container, 57 | trigger : trigger, 58 | emitter : this 59 | }); 60 | } 61 | 62 | /** 63 | * Default `action` lookup function. 64 | * @param {Element} trigger 65 | */ 66 | defaultAction(trigger) { 67 | return getAttributeValue('action', trigger); 68 | } 69 | 70 | /** 71 | * Default `target` lookup function. 72 | * @param {Element} trigger 73 | */ 74 | defaultTarget(trigger) { 75 | const selector = getAttributeValue('target', trigger); 76 | 77 | if (selector) { 78 | return document.querySelector(selector); 79 | } 80 | } 81 | 82 | /** 83 | * Returns the support of the given action, or all actions if no action is 84 | * given. 85 | * @param {String} [action] 86 | */ 87 | static isSupported(action = ['copy', 'cut']) { 88 | const actions = (typeof action === 'string') ? [action] : action; 89 | let support = !!document.queryCommandSupported; 90 | 91 | actions.forEach((action) => { 92 | support = support && !!document.queryCommandSupported(action); 93 | }); 94 | 95 | return support; 96 | } 97 | 98 | /** 99 | * Default `text` lookup function. 100 | * @param {Element} trigger 101 | */ 102 | defaultText(trigger) { 103 | return getAttributeValue('text', trigger); 104 | } 105 | 106 | /** 107 | * Destroy lifecycle. 108 | */ 109 | destroy() { 110 | this.listener.destroy(); 111 | 112 | if (this.clipboardAction) { 113 | this.clipboardAction.destroy(); 114 | this.clipboardAction = null; 115 | } 116 | } 117 | } 118 | 119 | 120 | /** 121 | * Helper function to retrieve attribute value. 122 | * @param {String} suffix 123 | * @param {Element} element 124 | */ 125 | function getAttributeValue(suffix, element) { 126 | const attribute = `data-clipboard-${suffix}`; 127 | 128 | if (!element.hasAttribute(attribute)) { 129 | return; 130 | } 131 | 132 | return element.getAttribute(attribute); 133 | } 134 | 135 | export default Clipboard; 136 | -------------------------------------------------------------------------------- /test/clipboard.js: -------------------------------------------------------------------------------- 1 | import Clipboard from '../src/clipboard'; 2 | import ClipboardAction from '../src/clipboard-action'; 3 | import listen from 'good-listener'; 4 | 5 | describe('Clipboard', () => { 6 | before(() => { 7 | global.button = document.createElement('button'); 8 | global.button.setAttribute('class', 'btn'); 9 | global.button.setAttribute('data-clipboard-text', 'foo'); 10 | document.body.appendChild(global.button); 11 | 12 | global.span = document.createElement('span'); 13 | global.span.innerHTML = 'bar'; 14 | 15 | global.button.appendChild(span); 16 | 17 | global.event = { 18 | target: global.button, 19 | currentTarget: global.button 20 | }; 21 | }); 22 | 23 | after(() => { 24 | document.body.innerHTML = ''; 25 | }); 26 | 27 | describe('#resolveOptions', () => { 28 | before(() => { 29 | global.fn = () => {}; 30 | }); 31 | 32 | it('should set action as a function', () => { 33 | let clipboard = new Clipboard('.btn', { 34 | action: global.fn 35 | }); 36 | 37 | assert.equal(global.fn, clipboard.action); 38 | }); 39 | 40 | it('should set target as a function', () => { 41 | let clipboard = new Clipboard('.btn', { 42 | target: global.fn 43 | }); 44 | 45 | assert.equal(global.fn, clipboard.target); 46 | }); 47 | 48 | it('should set text as a function', () => { 49 | let clipboard = new Clipboard('.btn', { 50 | text: global.fn 51 | }); 52 | 53 | assert.equal(global.fn, clipboard.text); 54 | }); 55 | 56 | it('should set container as an object', () => { 57 | let clipboard = new Clipboard('.btn', { 58 | container: document.body 59 | }); 60 | 61 | assert.equal(document.body, clipboard.container); 62 | }); 63 | 64 | it('should set container as body by default', () => { 65 | let clipboard = new Clipboard('.btn'); 66 | 67 | assert.equal(document.body, clipboard.container); 68 | }); 69 | }); 70 | 71 | describe('#listenClick', () => { 72 | it('should add a click event listener to the passed selector', () => { 73 | let clipboard = new Clipboard('.btn'); 74 | assert.isObject(clipboard.listener); 75 | }); 76 | }); 77 | 78 | describe('#onClick', () => { 79 | it('should create a new instance of ClipboardAction', () => { 80 | let clipboard = new Clipboard('.btn'); 81 | 82 | clipboard.onClick(global.event); 83 | assert.instanceOf(clipboard.clipboardAction, ClipboardAction); 84 | }); 85 | 86 | it('should use an event\'s currentTarget when not equal to target', () => { 87 | let clipboard = new Clipboard('.btn'); 88 | let bubbledEvent = { target: global.span, currentTarget: global.button }; 89 | 90 | clipboard.onClick(bubbledEvent); 91 | assert.instanceOf(clipboard.clipboardAction, ClipboardAction); 92 | }); 93 | 94 | it('should throw an exception when target is invalid', done => { 95 | try { 96 | const clipboard = new Clipboard('.btn', { 97 | target() { 98 | return null; 99 | } 100 | }); 101 | 102 | clipboard.onClick(global.event); 103 | } 104 | catch(e) { 105 | assert.equal(e.message, 'Invalid "target" value, use a valid Element'); 106 | done(); 107 | } 108 | }); 109 | }); 110 | 111 | describe('#static isSupported', () => { 112 | it('should return the support of the given action', () => { 113 | assert.equal(Clipboard.isSupported('copy'), true); 114 | assert.equal(Clipboard.isSupported('cut'), true); 115 | }); 116 | 117 | it('should return the support of the cut and copy actions', () => { 118 | assert.equal(Clipboard.isSupported(), true); 119 | }); 120 | }); 121 | 122 | describe('#destroy', () => { 123 | it('should destroy an existing instance of ClipboardAction', () => { 124 | let clipboard = new Clipboard('.btn'); 125 | 126 | clipboard.onClick(global.event); 127 | clipboard.destroy(); 128 | 129 | assert.equal(clipboard.clipboardAction, null); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/clipboard-action.js: -------------------------------------------------------------------------------- 1 | import select from 'select'; 2 | 3 | /** 4 | * Inner class which performs selection from either `text` or `target` 5 | * properties and then executes copy or cut operations. 6 | */ 7 | class ClipboardAction { 8 | /** 9 | * @param {Object} options 10 | */ 11 | constructor(options) { 12 | this.resolveOptions(options); 13 | this.initSelection(); 14 | } 15 | 16 | /** 17 | * Defines base properties passed from constructor. 18 | * @param {Object} options 19 | */ 20 | resolveOptions(options = {}) { 21 | this.action = options.action; 22 | this.container = options.container; 23 | this.emitter = options.emitter; 24 | this.target = options.target; 25 | this.text = options.text; 26 | this.trigger = options.trigger; 27 | 28 | this.selectedText = ''; 29 | } 30 | 31 | /** 32 | * Decides which selection strategy is going to be applied based 33 | * on the existence of `text` and `target` properties. 34 | */ 35 | initSelection() { 36 | if (this.text) { 37 | this.selectFake(); 38 | } 39 | else if (this.target) { 40 | this.selectTarget(); 41 | } 42 | } 43 | 44 | /** 45 | * Creates a fake textarea element, sets its value from `text` property, 46 | * and makes a selection on it. 47 | */ 48 | selectFake() { 49 | const isRTL = document.documentElement.getAttribute('dir') == 'rtl'; 50 | 51 | this.removeFake(); 52 | 53 | this.fakeHandlerCallback = () => this.removeFake(); 54 | this.fakeHandler = this.container.addEventListener('click', this.fakeHandlerCallback) || true; 55 | 56 | this.fakeElem = document.createElement('textarea'); 57 | // Prevent zooming on iOS 58 | this.fakeElem.style.fontSize = '12pt'; 59 | // Reset box model 60 | this.fakeElem.style.border = '0'; 61 | this.fakeElem.style.padding = '0'; 62 | this.fakeElem.style.margin = '0'; 63 | // Move element out of screen horizontally 64 | this.fakeElem.style.position = 'absolute'; 65 | this.fakeElem.style[ isRTL ? 'right' : 'left' ] = '-9999px'; 66 | // Move element to the same position vertically 67 | let yPosition = window.pageYOffset || document.documentElement.scrollTop; 68 | this.fakeElem.style.top = `${yPosition}px`; 69 | 70 | this.fakeElem.setAttribute('readonly', ''); 71 | this.fakeElem.value = this.text; 72 | 73 | this.container.appendChild(this.fakeElem); 74 | 75 | this.selectedText = select(this.fakeElem); 76 | this.copyText(); 77 | } 78 | 79 | /** 80 | * Only removes the fake element after another click event, that way 81 | * a user can hit `Ctrl+C` to copy because selection still exists. 82 | */ 83 | removeFake() { 84 | if (this.fakeHandler) { 85 | this.container.removeEventListener('click', this.fakeHandlerCallback); 86 | this.fakeHandler = null; 87 | this.fakeHandlerCallback = null; 88 | } 89 | 90 | if (this.fakeElem) { 91 | this.container.removeChild(this.fakeElem); 92 | this.fakeElem = null; 93 | } 94 | } 95 | 96 | /** 97 | * Selects the content from element passed on `target` property. 98 | */ 99 | selectTarget() { 100 | this.selectedText = select(this.target); 101 | this.copyText(); 102 | } 103 | 104 | /** 105 | * Executes the copy operation based on the current selection. 106 | */ 107 | copyText() { 108 | let succeeded; 109 | 110 | try { 111 | succeeded = document.execCommand(this.action); 112 | } 113 | catch (err) { 114 | succeeded = false; 115 | } 116 | 117 | this.handleResult(succeeded); 118 | } 119 | 120 | /** 121 | * Fires an event based on the copy operation result. 122 | * @param {Boolean} succeeded 123 | */ 124 | handleResult(succeeded) { 125 | this.emitter.emit(succeeded ? 'success' : 'error', { 126 | action: this.action, 127 | text: this.selectedText, 128 | trigger: this.trigger, 129 | clearSelection: this.clearSelection.bind(this) 130 | }); 131 | } 132 | 133 | /** 134 | * Moves focus away from `target` and back to the trigger, removes current selection. 135 | */ 136 | clearSelection() { 137 | if (this.trigger) { 138 | this.trigger.focus(); 139 | } 140 | document.activeElement.blur(); 141 | window.getSelection().removeAllRanges(); 142 | } 143 | 144 | /** 145 | * Sets the `action` to be performed which can be either 'copy' or 'cut'. 146 | * @param {String} action 147 | */ 148 | set action(action = 'copy') { 149 | this._action = action; 150 | 151 | if (this._action !== 'copy' && this._action !== 'cut') { 152 | throw new Error('Invalid "action" value, use either "copy" or "cut"'); 153 | } 154 | } 155 | 156 | /** 157 | * Gets the `action` property. 158 | * @return {String} 159 | */ 160 | get action() { 161 | return this._action; 162 | } 163 | 164 | /** 165 | * Sets the `target` property using an element 166 | * that will be have its content copied. 167 | * @param {Element} target 168 | */ 169 | set target(target) { 170 | if (target !== undefined) { 171 | if (target && typeof target === 'object' && target.nodeType === 1) { 172 | if (this.action === 'copy' && target.hasAttribute('disabled')) { 173 | throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); 174 | } 175 | 176 | if (this.action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) { 177 | throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); 178 | } 179 | 180 | this._target = target; 181 | } 182 | else { 183 | throw new Error('Invalid "target" value, use a valid Element'); 184 | } 185 | } 186 | } 187 | 188 | /** 189 | * Gets the `target` property. 190 | * @return {String|HTMLElement} 191 | */ 192 | get target() { 193 | return this._target; 194 | } 195 | 196 | /** 197 | * Destroy lifecycle. 198 | */ 199 | destroy() { 200 | this.removeFake(); 201 | } 202 | } 203 | 204 | export default ClipboardAction; 205 | -------------------------------------------------------------------------------- /test/clipboard-action.js: -------------------------------------------------------------------------------- 1 | import ClipboardAction from '../src/clipboard-action'; 2 | import Emitter from 'tiny-emitter'; 3 | 4 | describe('ClipboardAction', () => { 5 | before(() => { 6 | global.input = document.createElement('input'); 7 | global.input.setAttribute('id', 'input'); 8 | global.input.setAttribute('value', 'abc'); 9 | document.body.appendChild(global.input); 10 | 11 | global.paragraph = document.createElement('p'); 12 | global.paragraph.setAttribute('id', 'paragraph'); 13 | global.paragraph.textContent = 'abc'; 14 | document.body.appendChild(global.paragraph); 15 | }); 16 | 17 | after(() => { 18 | document.body.innerHTML = ''; 19 | }); 20 | 21 | describe('#resolveOptions', () => { 22 | it('should set base properties', () => { 23 | let clip = new ClipboardAction({ 24 | emitter: new Emitter(), 25 | container: document.body, 26 | text: 'foo' 27 | }); 28 | 29 | assert.property(clip, 'action'); 30 | assert.property(clip, 'container'); 31 | assert.property(clip, 'emitter'); 32 | assert.property(clip, 'target'); 33 | assert.property(clip, 'text'); 34 | assert.property(clip, 'trigger'); 35 | assert.property(clip, 'selectedText'); 36 | }); 37 | }); 38 | 39 | describe('#initSelection', () => { 40 | it('should set the position right style property', done => { 41 | // Set document direction 42 | document.documentElement.setAttribute('dir', 'rtl'); 43 | 44 | let clip = new ClipboardAction({ 45 | emitter: new Emitter(), 46 | container: document.body, 47 | text: 'foo' 48 | }); 49 | 50 | assert.equal(clip.fakeElem.style.right, '-9999px'); 51 | done(); 52 | }); 53 | }); 54 | 55 | describe('#set action', () => { 56 | it('should throw an error since "action" is invalid', done => { 57 | try { 58 | new ClipboardAction({ 59 | text: 'foo', 60 | action: 'paste' 61 | }); 62 | } 63 | catch(e) { 64 | assert.equal(e.message, 'Invalid "action" value, use either "copy" or "cut"'); 65 | done(); 66 | } 67 | }); 68 | }); 69 | 70 | describe('#set target', () => { 71 | it('should throw an error since "target" do not match any element', done => { 72 | try { 73 | new ClipboardAction({ 74 | target: document.querySelector('#foo') 75 | }); 76 | } 77 | catch(e) { 78 | assert.equal(e.message, 'Invalid "target" value, use a valid Element'); 79 | done(); 80 | } 81 | }); 82 | }); 83 | 84 | describe('#selectText', () => { 85 | it('should create a fake element and select its value', () => { 86 | let clip = new ClipboardAction({ 87 | emitter: new Emitter(), 88 | container: document.body, 89 | text: 'blah' 90 | }); 91 | 92 | assert.equal(clip.selectedText, clip.fakeElem.value); 93 | }); 94 | }); 95 | 96 | describe('#removeFake', () => { 97 | it('should remove a temporary fake element', () => { 98 | let clip = new ClipboardAction({ 99 | emitter: new Emitter(), 100 | container: document.body, 101 | text: 'blah' 102 | }); 103 | 104 | clip.removeFake(); 105 | 106 | assert.equal(clip.fakeElem, null); 107 | }); 108 | }); 109 | 110 | describe('#selectTarget', () => { 111 | it('should select text from editable element', () => { 112 | let clip = new ClipboardAction({ 113 | emitter: new Emitter(), 114 | container: document.body, 115 | target: document.querySelector('#input') 116 | }); 117 | 118 | assert.equal(clip.selectedText, clip.target.value); 119 | }); 120 | 121 | it('should select text from non-editable element', () => { 122 | let clip = new ClipboardAction({ 123 | emitter: new Emitter(), 124 | container: document.body, 125 | target: document.querySelector('#paragraph') 126 | }); 127 | 128 | assert.equal(clip.selectedText, clip.target.textContent); 129 | }); 130 | }); 131 | 132 | describe('#copyText', () => { 133 | before(() => { 134 | global.stub = sinon.stub(document, 'execCommand'); 135 | }); 136 | 137 | after(() => { 138 | global.stub.restore(); 139 | }); 140 | 141 | it('should fire a success event on browsers that support copy command', done => { 142 | global.stub.returns(true); 143 | 144 | let emitter = new Emitter(); 145 | 146 | emitter.on('success', () => { 147 | done(); 148 | }); 149 | 150 | let clip = new ClipboardAction({ 151 | emitter, 152 | target: document.querySelector('#input') 153 | }); 154 | }); 155 | 156 | it('should fire an error event on browsers that support copy command', done => { 157 | global.stub.returns(false); 158 | 159 | let emitter = new Emitter(); 160 | 161 | emitter.on('error', () => { 162 | done(); 163 | }); 164 | 165 | let clip = new ClipboardAction({ 166 | emitter, 167 | target: document.querySelector('#input') 168 | }); 169 | }); 170 | }); 171 | 172 | describe('#handleResult', () => { 173 | it('should fire a success event with certain properties', done => { 174 | let clip = new ClipboardAction({ 175 | emitter: new Emitter(), 176 | container: document.body, 177 | target: document.querySelector('#input') 178 | }); 179 | 180 | clip.emitter.on('success', (e) => { 181 | assert.property(e, 'action'); 182 | assert.property(e, 'text'); 183 | assert.property(e, 'trigger'); 184 | assert.property(e, 'clearSelection'); 185 | 186 | done(); 187 | }); 188 | 189 | clip.handleResult(true); 190 | }); 191 | 192 | it('should fire a error event with certain properties', done => { 193 | let clip = new ClipboardAction({ 194 | emitter: new Emitter(), 195 | container: document.body, 196 | target: document.querySelector('#input') 197 | }); 198 | 199 | clip.emitter.on('error', (e) => { 200 | assert.property(e, 'action'); 201 | assert.property(e, 'trigger'); 202 | assert.property(e, 'clearSelection'); 203 | 204 | done(); 205 | }); 206 | 207 | clip.handleResult(false); 208 | }); 209 | }); 210 | 211 | describe('#clearSelection', () => { 212 | it('should remove focus from target and text selection', () => { 213 | let clip = new ClipboardAction({ 214 | emitter: new Emitter(), 215 | container: document.body, 216 | target: document.querySelector('#input') 217 | }); 218 | 219 | clip.clearSelection(); 220 | 221 | let selectedElem = document.activeElement; 222 | let selectedText = window.getSelection().toString(); 223 | 224 | assert.equal(selectedElem, document.body); 225 | assert.equal(selectedText, ''); 226 | }); 227 | }); 228 | 229 | describe('#destroy', () => { 230 | it('should destroy an existing fake element', () => { 231 | let clip = new ClipboardAction({ 232 | emitter: new Emitter(), 233 | container: document.body, 234 | text: 'blah' 235 | }); 236 | 237 | clip.selectFake(); 238 | clip.destroy(); 239 | 240 | assert.equal(clip.fakeElem, null); 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # clipboard.js 2 | 3 | [![Build Status](http://img.shields.io/travis/zenorocha/clipboard.js/master.svg?style=flat)](https://travis-ci.org/zenorocha/clipboard.js) 4 | ![Killing Flash](https://img.shields.io/badge/killing-flash-brightgreen.svg?style=flat) 5 | 6 | > Modern copy to clipboard. No Flash. Just 3kb gzipped. 7 | 8 | Demo 9 | 10 | ## Why 11 | 12 | Copying text to the clipboard shouldn't be hard. It shouldn't require dozens of steps to configure or hundreds of KBs to load. But most of all, it shouldn't depend on Flash or any bloated framework. 13 | 14 | That's why clipboard.js exists. 15 | 16 | ## Install 17 | 18 | You can get it on npm. 19 | 20 | ``` 21 | npm install clipboard --save 22 | ``` 23 | 24 | Or if you're not into package management, just [download a ZIP](https://github.com/zenorocha/clipboard.js/archive/master.zip) file. 25 | 26 | ## Setup 27 | 28 | First, include the script located on the `dist` folder or load it from [a third-party CDN provider](https://github.com/zenorocha/clipboard.js/wiki/CDN-Providers). 29 | 30 | ```html 31 | 32 | ``` 33 | 34 | Now, you need to instantiate it by [passing a DOM selector](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-selector.html#L18), [HTML element](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-node.html#L16-L17), or [list of HTML elements](https://github.com/zenorocha/clipboard.js/blob/master/demo/constructor-nodelist.html#L18-L19). 35 | 36 | ```js 37 | new ClipboardJS('.btn'); 38 | ``` 39 | 40 | Internally, we need to fetch all elements that matches with your selector and attach event listeners for each one. But guess what? If you have hundreds of matches, this operation can consume a lot of memory. 41 | 42 | For this reason we use [event delegation](https://stackoverflow.com/questions/1687296/what-is-dom-event-delegation) which replaces multiple event listeners with just a single listener. After all, [#perfmatters](https://twitter.com/hashtag/perfmatters). 43 | 44 | # Usage 45 | 46 | We're living a _declarative renaissance_, that's why we decided to take advantage of [HTML5 data attributes](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_data_attributes) for better usability. 47 | 48 | ### Copy text from another element 49 | 50 | A pretty common use case is to copy content from another element. You can do that by adding a `data-clipboard-target` attribute in your trigger element. 51 | 52 | The value you include on this attribute needs to match another's element selector. 53 | 54 | example-2 55 | 56 | ```html 57 | 58 | 59 | 60 | 61 | 64 | ``` 65 | 66 | ### Cut text from another element 67 | 68 | Additionally, you can define a `data-clipboard-action` attribute to specify if you want to either `copy` or `cut` content. 69 | 70 | If you omit this attribute, `copy` will be used by default. 71 | 72 | example-3 73 | 74 | ```html 75 | 76 | 77 | 78 | 79 | 82 | ``` 83 | 84 | As you may expect, the `cut` action only works on `` or `