Creating A Simple Copy-To-Clipboard Directive In Angular 2.4.9
A couple of months ago, I happened to see Will Boyd on Twitter mention that JavaScript-based Clipboard support was fairly strong in modern browsers. This piqued my interest because, here at InVision, for the longest time, we had to use a Flash movie (remember those) to power our "copy to clipboard" functionality. Now that JavaScript access to the Clipboard is well supported, I wanted to see how easy it would be to create a Copy-to-Clipboard directive in Angular 2.
Run this demo in my JavaScript Demos project on GitHub.
In the Browser, access to the clipboard can be partially granted using the Document.execCommand(), which I have almost no experience with. I think I vaguely remember messing with it back in the day when Rich Text Editors in the browser where more popular. But, I haven't used it since. Regardless, you can copy text to the clipboard using:
document.execCommand( "copy" );
Notice that we're not passing any target text to the .execCommand() method. This is because the "copy" command copies the current Selection on the page - ie, the text you have highlighted. This could be static text within the Document Object Model (DOM); or, it could be text you have selected within an input field. This means that if we want to copy arbitrary text to the clipboard, we have to programmatically create a Selection on the page before we call the .execCommand() method.
If you look around at the various "copy to clipboard" snippets, they all use the same approach. They either call .select() on an existing form input element; or, they inject a transient input element, populate it, call .select(), and then remove it from the DOM. Since I want to try to create a more general solution, I'm going with the latter approach.
First, I wanted to create a ClipboardService that would completely isolate the mechanism through which we accessed the system clipboard. This way, the rest of my Angular 2 application only ever has to interact with the ClipboardService interface and never directly with the DOM or the document.execCommand() method. And, I wanted to be able to return a Promise:
// Import the core angular services.
import { DOCUMENT } from "@angular/platform-browser";
import { Inject } from "@angular/core";
import { Injectable } from "@angular/core";
@Injectable()
export class ClipboardService {
private dom: Document;
// I initialize the Clipboard service.
// --
// CAUTION: This service is tightly couped to the browser DOM (Document Object Model).
// But, by injecting the "document" reference rather than trying to reference it
// globally, we can at least pretend that we are trying to lower the tight coupling.
constructor( @Inject( DOCUMENT ) dom: Document ) {
this.dom = dom;
}
// ---
// PUBLIC METHODS.
// ---
// I copy the given value to the user's system clipboard. Returns a promise that
// resolves to the given value on success or rejects with the raised Error.
public copy( value: string ) : Promise<string> {
var promise = new Promise(
( resolve, reject ) : void => {
var textarea = null;
try {
// In order to execute the "Copy" command, we actually have to have
// a "selection" in the currently rendered document. As such, we're
// going to inject a Textarea element and .select() it in order to
// force a selection.
// --
// NOTE: This Textarea is being rendered off-screen.
textarea = this.dom.createElement( "textarea" );
textarea.style.height = "0px";
textarea.style.left = "-100px";
textarea.style.opacity = "0";
textarea.style.position = "fixed";
textarea.style.top = "-100px";
textarea.style.width = "0px";
this.dom.body.appendChild( textarea );
// Set and select the value (creating an active Selection range).
textarea.value = value;
textarea.select();
// Ask the browser to copy the current selection to the clipboard.
this.dom.execCommand( "copy" );
resolve( value );
} finally {
// Cleanup - remove the Textarea from the DOM if it was injected.
if ( textarea && textarea.parentNode ) {
textarea.parentNode.removeChild( textarea );
}
}
}
);
return( promise );
}
}
As you can see, when you call the .copy() method on the ClipboardService, we inject a transient Textarea element into the rendered document, render it off-screen, populate it, highlight it - creating an active Selection - call document.execCommand(), and then remove the Textarea element from the DOM. If this completes successfully, we resolve the promise with the given text.
Clearly, this service is tightly coupled to the Browser runtime; but, instead of just pulling the "document" out of the global context, I'm trying to be a good citizen by injecting the "DOCUMENT" dependency into the service. This way, should we ever feel the masochistic need to, we could mock out the DOM and unit-test this service outside of the Browser runtime.
Now that we have a service that provides a clean, programmatic interface to the system clipboard, we could certainly use this service directly. But, I know that the vast majority of the use-cases will be to have the user click a button and have some text copied to the clipboard. As such, I wanted to create a small Attribute Directive that would glue a click event to the ClipboardService. Something like:
<button [clipboard]="Text to copy">Copy to Clipboard</button>
Here, the [clipboard] input is the selector for the Attribute Directive which, upon a (click) event, will copy the [clipboard] input to the system clipboard using the ClipboardService:
// Import the core angular services.
import { Directive } from "@angular/core";
import { EventEmitter } from "@angular/core";
// Import the application components and services.
import { ClipboardService } from "./clipboard.service";
// This directive acts as a simple glue layer between the given [clipboard] property
// and the underlying ClipboardService. Upon the (click) event, the [clipboard] value
// will be copied to the ClipboardService and a (clipboardCopy) event will be emitted.
@Directive({
selector: "[clipboard]",
inputs: [ "value: clipboard" ],
outputs: [
"copyEvent: clipboardCopy",
"errorEvent: clipboardError"
],
host: {
"(click)": "copyToClipboard()"
}
})
export class ClipboardDirective {
public copyEvent: EventEmitter<string>;
public errorEvent: EventEmitter<Error>;
public value: string;
private clipboardService: ClipboardService;
// I initialize the clipboard directive.
constructor( clipboardService: ClipboardService ) {
this.clipboardService = clipboardService;
this.copyEvent = new EventEmitter();
this.errorEvent = new EventEmitter();
this.value = "";
}
// ---
// PUBLIC METODS.
// ---
// I copy the value-input to the Clipboard. Emits success or error event.
public copyToClipboard() : void {
this.clipboardService
.copy( this.value )
.then(
( value: string ) : void => {
this.copyEvent.emit( value );
}
)
.catch(
( error: Error ) : void => {
this.errorEvent.emit( error );
}
)
;
}
}
As you can see, this attribute directive takes the clipboard text as an input property and then emits two output events, one for success and one for error. And, since the ClipboardService completely abstracts access to the clipboard, this glue layer becomes as easy as a method call and some Promise handlers.
Now, in the root component, we can use this glue directive to setup a few buttons that will copy various text values to the clipboard:
// Import the core angular services.
import { Component } from "@angular/core";
@Component({
moduleId: module.id,
selector: "my-app",
styleUrls: [ "./app.component.css" ],
template:
`
<p>
<button
[clipboard]="value1.innerHTML.trim()"
(clipboardCopy)="logSuccess( $event )"
(clipboardError)="logError( $event )">
Copy Text
</button>
<span #value1>
Hello World!
</span>
</p>
<p>
<button
[clipboard]="value2.innerHTML.trim()"
(clipboardCopy)="logSuccess( $event )"
(clipboardError)="logError( $event )">
Copy Text
</button>
<span #value2>
Rock on With Yer Bad Self!
</span>
</p>
<p>
<button
[clipboard]="value3.innerHTML.trim()"
(clipboardCopy)="logSuccess( $event )"
(clipboardError)="logError( $event )">
Copy Text
</button>
<span #value3>
Weeezing The Ju-uice!
</span>
</p>
<textarea
#tester
(click)="tester.select()"
placeholder="Test your Copy operation here..."
></textarea>
`
})
export class AppComponent {
// I initialize the app component.
constructor() {
// ...
}
// ---
// PUBLIC METODS.
// ---
// I log Clipboard "copy" errors.
public logError( error: Error ) : void {
console.group( "Clipboard Error" );
console.error( error );
console.groupEnd();
}
// I log Clipboard "copy" successes.
public logSuccess( value: string ) : void {
console.group( "Clipboard Success" );
console.log( value );
console.groupEnd();
}
}
For this demo, I'm just using simple Angular 2 DOM references to provide text (as innerHTML) to the [clipboard] input property of the glue directive; but, of course, you can use any public property of the root component's view-model to provide the text. You could even use static text. This just seemed like the easiest thing to demo.
And, when we run the above code, you can see that clicking the various buttons copies the text and makes it available for pasting:
Pretty cool stuff! What I especially like about this approach is that we're really isolating access to the clipboard so that if it the access requirements ever change, neither the root component nor the "glue" Attribute Directive need to care; only the internal implementation of the ClipboardService will need to change. We've kept it clean, isolating the things that change for different reasons.
Want to use code from this post? Check out the license.
Reader Comments
Hey, cool article.
At first the injection of dom seemed like an overkill but the argumentation convinced me. It's a lot cleaner and testable.
I see a potential issue: If user has already selected something on the page that selection will be overwritten by the directive. Ultimately user will lose selection.
@Georgi,
Yeah, that's true. Though, presumably, if the user is clicking on a button to initiate the copy-to-clipboard, I believe the click will also cause the user to lose their current selection. So, I don't think it will be too much of a concern.
Works well, thank you very much!
Thx for sharing !
Here is the existing solution https://github.com/maxisam/ngx-clipboard
I actually thought about make it pure angular library as well.
However, in this case, I feel like it is sorta recreating the wheel that doesn't really gain much benefit.
@Philippe,
Groovy - glad you liked it.
@Sam,
I can dig it - I mostly wanted to create it as a Service so that I could learn more about how the Clipboard works. Plus, I like the practice of trying to think about how to build things with lower coupling. But, I totally agree -- definitely re-inventing the wheel. If my requirements were more complex, I might take the Clipboard.js library that you are using and just wrap that inside my ClipboardSevice.ts (so that I could create my own API). Good stuff!
@Sam,
Your's only copies input fields (as far as I saw). The solution here let's you copy any text which was exactly what I was looking for. Thanks! I have an ngFor loop printing ip addresses and I needed something where a user could click a button and copy them. This was perfect! ??
@Mike
You saw it wrong. It supports both. I actually convert ngx-clipboard to pure Angular code for providing better tree-shake support and testibility.
Hi,
First of all thank you for sharing this! It was helpful also to see the new syntax for the Directives.
I would like to know why you use public "everywhere", it works widthout too, what is the purpose?
Also, I was unable to make it work on an IOS. The service return "success" but it's not available in the softkeyboard to copy. Is there any hint you could give me to explore ?
Thank you again!
I tried Sam's solution and this solution on Angular 4 with Universal (ExpressJS).
Ben's solution worked fine, but has an issue with ES5 in Sam's solution, i think.
Thank you so much, Ben and Sam!
@Bjemtj,
If you use ES5, make sure you use the umd.js in bundles folder.
To hide the textarea you can simply do this:
textarea.style.display = "none";
i got error 'core.es5.js:1020 ERROR Error: Uncaught (in promise): Error: No provider for Document!
Error: No provider for Document!'
i have follow all step.can you explain me why this error occurred? thanks in advanced.
@Brinda,
Hmmm, what version of Angular are you using? I think the DOCUMENT may have been moved from "platform-browser" to "@angular/common" in recent versions. Also, are you using something like Ahead of Time (AoT) compiling? It's possible that you are preparing the code to be run in an environment that does not have a Document object available?
@Maicmelan,
Interesting. I had just assumed that a hidden input could not have a selection associated with it. I never actually tested it. Will have to try that out.
excellent work man ! Very good explanation !
Cool tutorial, trying to use it on a page where the words in the copy to clipboard area are randomly generated from a wordlist, im hitting this error on page load ExpressionChangedAfterItHasBeenCheckedError, any idea of a work around for it
RegistrationPageComponent.html:6 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'value: <!---->'. Current value: 'value: <!--bindings={
"ng-reflect-ng-for-of": "stem,oval,cigar,hood,fortune,d"
@Sam,
I tried to use the ngx-clipboard and it wouldn't work it was throwing type errors. I ended up using this and it works great! thank you very much!