Skip to content

Commit e722855

Browse files
committed
feat(docs): add copy buttons to markdown code blocks
Add copy functionality to all code blocks in documentation markdown content. Previously, only example viewer code and module import snippets had copy buttons, but regular markdown code blocks (like configuration examples) were missing this feature.
1 parent 9951497 commit e722855

File tree

5 files changed

+117
-1
lines changed

5 files changed

+117
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>code-block-copy-button works!</p>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Component, inject} from '@angular/core';
10+
import {MatIconButton} from '@angular/material/button';
11+
import {MatIcon} from '@angular/material/icon';
12+
import {MatSnackBar} from '@angular/material/snack-bar';
13+
import {MatTooltip} from '@angular/material/tooltip';
14+
import {Clipboard} from '@angular/cdk/clipboard';
15+
16+
@Component({
17+
selector: 'code-block-copy-button',
18+
imports: [MatIconButton, MatIcon, MatTooltip],
19+
template: `
20+
<button mat-icon-button matTooltip="Copy code to the clipboard" (click)="copy()">
21+
<mat-icon>content_copy</mat-icon>
22+
</button>
23+
`,
24+
})
25+
export class CodeBlockCopyButton {
26+
private _clipboard = inject(Clipboard);
27+
private _snackbar = inject(MatSnackBar);
28+
29+
/** Code snippet that will be copied */
30+
code = '';
31+
32+
copy(): void {
33+
const message = this._clipboard.copy(this.code)
34+
? 'Copied code snippet'
35+
: 'Failed to copy code snippet';
36+
37+
this._snackbar.open(message, undefined, {duration: 2500});
38+
}
39+
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,41 @@ describe('DocViewer', () => {
211211
expect(clipboardSpy.copy).toHaveBeenCalled();
212212
});
213213

214+
it('should show copy icon button for code blocks', () => {
215+
const fixture = TestBed.createComponent(DocViewerTestComponent);
216+
fixture.componentInstance.documentUrl = `http://material.angular.io/doc-with-code-block.html`;
217+
fixture.detectChanges();
218+
219+
const url = fixture.componentInstance.documentUrl;
220+
http.expectOne(url).flush(FAKE_DOCS[url]);
221+
222+
const docViewer = fixture.debugElement.query(By.directive(DocViewer));
223+
expect(docViewer).not.toBeNull();
224+
225+
// Query all copy buttons within code blocks
226+
const iconButtons = fixture.debugElement.queryAll(By.directive(MatIconButton));
227+
// At least one icon button for copying code should exist
228+
expect(iconButtons.length).toBeGreaterThan(0);
229+
230+
// Click on the first icon button to trigger copying the code
231+
iconButtons[0].nativeNode.dispatchEvent(new MouseEvent('click'));
232+
fixture.detectChanges();
233+
expect(clipboardSpy.copy).toHaveBeenCalledWith('const example = "test code";');
234+
});
235+
236+
it('should create copy buttons for multiple code blocks', () => {
237+
const fixture = TestBed.createComponent(DocViewerTestComponent);
238+
fixture.componentInstance.documentUrl = `http://material.angular.io/doc-with-multiple-code-blocks.html`;
239+
fixture.detectChanges();
240+
241+
const url = fixture.componentInstance.documentUrl;
242+
http.expectOne(url).flush(FAKE_DOCS[url]);
243+
244+
const iconButtons = fixture.debugElement.queryAll(By.directive(MatIconButton));
245+
// Should have 3 copy buttons for 3 code blocks
246+
expect(iconButtons.length).toBe(3);
247+
});
248+
214249
// TODO(mmalerba): Add test that example-viewer is instantiated.
215250
});
216251

@@ -262,6 +297,10 @@ const FAKE_DOCS: {[key: string]: string} = {
262297
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
263298
</div>
264299
</div>`,
300+
'http://material.angular.io/doc-with-code-block.html': `
301+
<div class="docs-markdown">
302+
<pre><code>const example = "test code";</code></pre>
303+
</div>`,
265304
};
266305

267306
@Component({

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {ExampleViewer} from '../example-viewer/example-viewer';
3838
import {HeaderLink} from './header-link';
3939
import {DeprecatedFieldComponent} from './deprecated-tooltip';
4040
import {ModuleImportCopyButton} from './module-import-copy-button';
41+
import {CodeBlockCopyButton} from './code-block-copy-button/code-block-copy-button';
4142

4243
@Injectable({providedIn: 'root'})
4344
class DocFetcher {
@@ -160,6 +161,9 @@ export class DocViewer implements OnDestroy {
160161
// Create icon buttons to copy module import
161162
this._createCopyIconForModule();
162163

164+
// Create icon button for code block
165+
this._createCopyButtonsForCodeBlocks();
166+
163167
// Resolving and creating components dynamically in Angular happens synchronously, but since
164168
// we want to emit the output if the components are actually rendered completely, we wait
165169
// until the Angular zone becomes stable.
@@ -267,4 +271,30 @@ export class DocViewer implements OnDestroy {
267271
this._portalHosts.push(elementPortalOutlet);
268272
});
269273
}
274+
275+
_createCopyButtonsForCodeBlocks() {
276+
// Query all <pre> tags that contain <code> elements (markdown code blocks)
277+
const codeBlockElements = this._elementRef.nativeElement.querySelectorAll(
278+
'.docs-markdown pre:has(code)',
279+
);
280+
281+
[...codeBlockElements].forEach((element: HTMLElement) => {
282+
// Extract the text content from the code block
283+
const codeElement = element.querySelector('code');
284+
const codeSnippet = codeElement?.textContent || '';
285+
286+
const elementPortalOutlet = new DomPortalOutlet(element, this._appRef, this._injector);
287+
const codeBlockCopyButtonPortal = new ComponentPortal(
288+
CodeBlockCopyButton,
289+
this._viewContainerRef,
290+
);
291+
const codeBlockCopyButtonOutlet = elementPortalOutlet.attach(codeBlockCopyButtonPortal);
292+
293+
if (codeSnippet) {
294+
codeBlockCopyButtonOutlet.instance.code = codeSnippet;
295+
}
296+
297+
this._portalHosts.push(elementPortalOutlet);
298+
});
299+
}
270300
}

docs/src/styles/_markdown.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,22 @@
7272
overflow-x: auto;
7373
padding: 20px;
7474
white-space: pre-wrap;
75-
7675
border: solid 1px var(--mat-sys-outline-variant);
7776
border-radius: 12px;
77+
position: relative;
7878

7979
code {
8080
background: transparent;
8181
padding: 0;
8282
font-size: 100%;
8383
}
84+
85+
code-block-copy-button {
86+
position: absolute;
87+
top: 5px;
88+
right: 5px;
89+
z-index: 2;
90+
}
8491
}
8592

8693
code {

0 commit comments

Comments
 (0)