Skip to content
This repository was archived by the owner on Dec 18, 2024. It is now read-only.

Commit c812fad

Browse files
willshowelljelbourn
authored andcommitted
feat(example-viewer): add copy source button (#262)
1 parent 0741986 commit c812fad

File tree

9 files changed

+203
-9
lines changed

9 files changed

+203
-9
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* This class is based on the code in the following projects:
3+
*
4+
* - https://github.com/zenorocha/select
5+
* - https://github.com/zenorocha/clipboard.js/
6+
*
7+
* Both released under MIT license - © Zeno Rocha
8+
*/
9+
import {Injectable} from '@angular/core';
10+
11+
@Injectable()
12+
export class CopierService {
13+
14+
private textarea: HTMLTextAreaElement;
15+
16+
/** Copy the text value to the clipboard. */
17+
copyText(text: string): boolean {
18+
this.createTextareaAndSelect(text);
19+
20+
const copySuccessful = document.execCommand('copy');
21+
this.removeFake();
22+
23+
return copySuccessful;
24+
}
25+
26+
/**
27+
* Creates a hidden textarea element, sets its value from `text` property,
28+
* and makes a selection on it.
29+
*/
30+
private createTextareaAndSelect(text: string) {
31+
// Create a fake element to hold the contents to copy
32+
this.textarea = document.createElement('textarea');
33+
34+
// Prevent zooming on iOS
35+
this.textarea.style.fontSize = '12pt';
36+
37+
// Hide the element
38+
this.textarea.classList.add('cdk-visually-hidden');
39+
40+
// Move element to the same position vertically
41+
const yPosition = window.pageYOffset || document.documentElement.scrollTop;
42+
this.textarea.style.top = yPosition + 'px';
43+
44+
this.textarea.setAttribute('readonly', '');
45+
this.textarea.value = text;
46+
47+
document.body.appendChild(this.textarea);
48+
49+
this.textarea.select();
50+
this.textarea.setSelectionRange(0, this.textarea.value.length);
51+
}
52+
53+
/** Remove the text area from the DOM. */
54+
private removeFake() {
55+
if (this.textarea) {
56+
document.body.removeChild(this.textarea);
57+
this.textarea = null;
58+
}
59+
}
60+
}

src/app/shared/doc-viewer/doc-viewer-module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import {
66
MdIconModule,
77
MdTabsModule,
88
MdTooltipModule,
9+
MdSnackBarModule,
910
PortalModule
1011
} from '@angular/material';
1112
import {CommonModule} from '@angular/common';
1213
import {NgModule} from '@angular/core';
1314
import {HeaderLink} from './header-link';
15+
import {CopierService} from '../copier/copier.service';
1416

1517

1618
// ExampleViewer is included in the DocViewerModule because they have a circular dependency.
@@ -19,11 +21,13 @@ import {HeaderLink} from './header-link';
1921
MdButtonModule,
2022
MdIconModule,
2123
MdTooltipModule,
24+
MdSnackBarModule,
2225
MdTabsModule,
2326
CommonModule,
2427
PortalModule,
2528
PlunkerButtonModule
2629
],
30+
providers: [CopierService],
2731
declarations: [DocViewer, ExampleViewer, HeaderLink],
2832
entryComponents: [ExampleViewer, HeaderLink],
2933
exports: [DocViewer, ExampleViewer, HeaderLink],

src/app/shared/doc-viewer/doc-viewer.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ describe('DocViewer', () => {
3232
expect(docViewer.nativeElement.innerHTML).toBe('<div>my docs page</div>');
3333
});
3434

35+
it('should save textContent of the doc', () => {
36+
let fixture = TestBed.createComponent(DocViewerTestComponent);
37+
fixture.detectChanges();
38+
39+
let docViewer = fixture.debugElement.query(By.directive(DocViewer));
40+
expect(docViewer.componentInstance.textContent).toBe('my docs page');
41+
});
42+
3543
it('should show error message when doc not found', () => {
3644
let fixture = TestBed.createComponent(DocViewerTestComponent);
3745
fixture.detectChanges();

src/app/shared/doc-viewer/doc-viewer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export class DocViewer implements OnDestroy {
3232

3333
@Output() contentLoaded = new EventEmitter<void>();
3434

35+
/** The document text. It should not be HTML encoded. */
36+
textContent = '';
37+
3538
constructor(private _appRef: ApplicationRef,
3639
private _componentFactoryResolver: ComponentFactoryResolver,
3740
private _elementRef: ElementRef,
@@ -51,6 +54,7 @@ export class DocViewer implements OnDestroy {
5154
// TODO(mmalerba): Trust HTML.
5255
if (response.ok) {
5356
this._elementRef.nativeElement.innerHTML = response.text();
57+
this.textContent = this._elementRef.nativeElement.textContent;
5458
this._loadComponents('material-docs-example', ExampleViewer);
5559
this._loadComponents('header-link', HeaderLink);
5660
this.contentLoaded.next();

src/app/shared/example-viewer/_example-viewer-theme.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
background: rgba(mat-color($foreground, secondary-text), .03);
1818
}
1919

20+
.docs-example-source-copy {
21+
color: mat-color($foreground, hint-text);
22+
right: 8px;
23+
24+
[dir='rtl'] & {
25+
right: auto;
26+
left: 8px;
27+
}
28+
}
29+
2030
.docs-example-source {
2131
border-bottom: 1px solid mat-color($foreground, divider);
2232
}

src/app/shared/example-viewer/example-viewer.html

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@
1919
<md-tab-group>
2020
<!-- TODO(jelbourn): don't hard-code the html + ts + css structure -->
2121
<md-tab *ngFor="let extension of ['HTML', 'TS', 'CSS']" [label]="extension">
22-
<pre *ngIf="exampleFileUrl(extension)" class="docs-example-source"><doc-viewer
23-
[documentUrl]="exampleFileUrl(extension)"></doc-viewer></pre>
22+
<div class="docs-example-source-wrapper">
23+
<button md-icon-button type="button" class="docs-example-source-copy"
24+
title="Copy example source" aria-label="Copy example source to clipboard"
25+
(click)="copySource(viewer.textContent)">
26+
<md-icon>content_copy</md-icon>
27+
</button>
28+
<pre class="docs-example-source"><doc-viewer
29+
#viewer [documentUrl]="exampleFileUrl(extension)"></doc-viewer></pre>
30+
</div>
2431
</md-tab>
2532
</md-tab-group>
2633
</div>

src/app/shared/example-viewer/example-viewer.scss

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,20 @@
2121
flex: 1 1 auto;
2222
}
2323

24+
.docs-example-source-copy {
25+
position: absolute;
26+
top: 8px;
27+
display: none;
28+
}
29+
30+
.docs-example-source-wrapper:hover {
31+
.docs-example-source-copy {
32+
display: inline-block;
33+
}
34+
}
35+
2436
.docs-example-source {
25-
padding: 0 0 10px 30px;
37+
padding: 0 30px 10px 30px;
2638
min-height: 150px;
2739
}
2840

src/app/shared/example-viewer/example-viewer.spec.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import {ReactiveFormsModule} from '@angular/forms';
2-
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
2+
import {async, inject, ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {MockBackend} from '@angular/http/testing';
4+
import {Response, ResponseOptions} from '@angular/http';
5+
import {By} from '@angular/platform-browser';
36

47
import {EXAMPLE_COMPONENTS} from '@angular/material-examples';
58
import {ExampleViewer} from './example-viewer';
@@ -14,12 +17,15 @@ import {
1417
MdInputModule,
1518
MdSlideToggleModule
1619
} from '@angular/material';
20+
import {CopierService} from '../copier/copier.service';
21+
import {MdSnackBar} from '@angular/material';
1722

1823
const exampleKey = 'autocomplete-overview';
1924

2025

2126
describe('ExampleViewer', () => {
2227
let fixture: ComponentFixture<ExampleViewer>;
28+
let component: ExampleViewer;
2329

2430
beforeEach(async(() => {
2531
TestBed.configureTestingModule({
@@ -32,20 +38,27 @@ describe('ExampleViewer', () => {
3238
}).compileComponents();
3339
}));
3440

41+
beforeEach(inject([MockBackend], (mockBackend: MockBackend) => {
42+
// Mock backend request that may come through the doc viewer
43+
mockBackend.connections.subscribe((connection: any) => {
44+
const url = connection.request.url;
45+
connection.mockRespond(getFakeDocResponse(url));
46+
});
47+
}));
48+
3549
beforeEach(() => {
3650
fixture = TestBed.createComponent(ExampleViewer);
51+
component = fixture.componentInstance;
3752
});
3853

3954
it('should toggle showSource boolean', () => {
40-
const component = fixture.componentInstance;
4155
fixture.detectChanges();
4256
expect(component.showSource).toBe(false);
4357
component.toggleSourceView();
4458
expect(component.showSource).toBe(true);
4559
});
4660

4761
it('should set and return example properly', () => {
48-
const component = fixture.componentInstance;
4962
component.example = exampleKey;
5063
fixture.detectChanges();
5164
const data = component.exampleData;
@@ -54,7 +67,6 @@ describe('ExampleViewer', () => {
5467
});
5568

5669
it('should log message about missing example', () => {
57-
const component = fixture.componentInstance;
5870
spyOn(console, 'log');
5971
component.example = 'foobar';
6072
fixture.detectChanges();
@@ -64,7 +76,6 @@ describe('ExampleViewer', () => {
6476

6577
it('should return assets path for example based on extension', () => {
6678
// set example
67-
const component = fixture.componentInstance;
6879
component.example = exampleKey;
6980
fixture.detectChanges();
7081

@@ -77,6 +88,50 @@ describe('ExampleViewer', () => {
7788
expect(actual).toEqual(expected);
7889
});
7990
});
91+
92+
describe('copy button', () => {
93+
let button: HTMLElement;
94+
95+
beforeEach(() => {
96+
// Open source view
97+
component.example = exampleKey;
98+
component.showSource = true;
99+
fixture.detectChanges();
100+
101+
// Select button element
102+
const btnDe = fixture.debugElement.query(By.css('.docs-example-source-copy'));
103+
button = btnDe ? btnDe.nativeElement : null;
104+
});
105+
106+
it('should call copier service when clicked', () => {
107+
const copierService: CopierService = TestBed.get(CopierService);
108+
const spy = spyOn(copierService, 'copyText');
109+
expect(spy.calls.count()).toBe(0, 'before click');
110+
button.click();
111+
expect(spy.calls.count()).toBe(1, 'after click');
112+
expect(spy.calls.argsFor(0)[0]).toBe('my docs page', 'click content');
113+
});
114+
115+
it('should display a message when copy succeeds', () => {
116+
const snackBar: MdSnackBar = TestBed.get(MdSnackBar);
117+
const copierService: CopierService = TestBed.get(CopierService);
118+
spyOn(snackBar, 'open');
119+
spyOn(copierService, 'copyText').and.returnValue(true);
120+
button.click();
121+
expect(snackBar.open).toHaveBeenCalledWith('Code copied', '', {duration: 2500});
122+
});
123+
124+
it('should display an error when copy fails', () => {
125+
const snackBar: MdSnackBar = TestBed.get(MdSnackBar);
126+
const copierService: CopierService = TestBed.get(CopierService);
127+
spyOn(snackBar, 'open');
128+
spyOn(copierService, 'copyText').and.returnValue(false);
129+
button.click();
130+
expect(snackBar.open)
131+
.toHaveBeenCalledWith('Copy failed. Please try again!', '', {duration: 2500});
132+
});
133+
});
134+
80135
});
81136

82137

@@ -96,3 +151,24 @@ describe('ExampleViewer', () => {
96151
entryComponents: [EXAMPLE_COMPONENTS[exampleKey].component],
97152
})
98153
class TestExampleModule { }
154+
155+
156+
const FAKE_DOCS = {
157+
'/assets/examples/autocomplete-overview-example-html.html':
158+
'<div>my docs page</div>',
159+
'/assets/examples/autocomplete-overview-example-ts.html':
160+
'<span>const a = 1;</span>',
161+
'/assets/examples/autocomplete-overview-example-css.html':
162+
'<pre>.class { color: black; }</pre>',
163+
};
164+
165+
function getFakeDocResponse(url: string) {
166+
if (url in FAKE_DOCS) {
167+
return new Response(new ResponseOptions({
168+
status: 200,
169+
body: FAKE_DOCS[url],
170+
}));
171+
} else {
172+
return new Response(new ResponseOptions({status: 404}));
173+
}
174+
}

src/app/shared/example-viewer/example-viewer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {Component, Input} from '@angular/core';
2-
import {ComponentPortal} from '@angular/material';
2+
import {ComponentPortal, MdSnackBar} from '@angular/material';
33
import 'rxjs/add/operator/first';
44

55
import {EXAMPLE_COMPONENTS, LiveExample} from '@angular/material-examples';
6+
import {CopierService} from '../copier/copier.service';
67

78

89
@Component({
@@ -22,6 +23,10 @@ export class ExampleViewer {
2223
/** Whether the source for the example is being displayed. */
2324
showSource = false;
2425

26+
constructor(
27+
private snackbar: MdSnackBar,
28+
private copier: CopierService) { }
29+
2530
get example() {
2631
return this._example;
2732
}
@@ -44,4 +49,12 @@ export class ExampleViewer {
4449
exampleFileUrl(extension: string) {
4550
return `/assets/examples/${this.example}-example-${extension.toLowerCase()}.html`;
4651
}
52+
53+
copySource(text: string) {
54+
if (this.copier.copyText(text)) {
55+
this.snackbar.open('Code copied', '', {duration: 2500});
56+
} else {
57+
this.snackbar.open('Copy failed. Please try again!', '', {duration: 2500});
58+
}
59+
}
4760
}

0 commit comments

Comments
 (0)