Skip to content

Commit b0bfc5d

Browse files
committed
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. 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 eb6ace5 commit b0bfc5d

File tree

4 files changed

+102
-1
lines changed

4 files changed

+102
-1
lines changed
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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,28 @@ describe('DocViewer', () => {
209209
expect(clipboardSpy.copy).toHaveBeenCalled();
210210
});
211211

212+
it('should show copy icon button for code blocks', () => {
213+
const fixture = TestBed.createComponent(DocViewerTestComponent);
214+
fixture.componentInstance.documentUrl = `http://material.angular.io/doc-with-code-block.html`;
215+
fixture.detectChanges();
216+
217+
const url = fixture.componentInstance.documentUrl;
218+
http.expectOne(url).flush(FAKE_DOCS[url]);
219+
220+
const docViewer = fixture.debugElement.query(By.directive(DocViewer));
221+
expect(docViewer).not.toBeNull();
222+
223+
// Query all copy buttons within code blocks
224+
const iconButtons = fixture.debugElement.queryAll(By.directive(MatIconButton));
225+
// At least one icon button for copying code should exist
226+
expect(iconButtons.length).toBeGreaterThan(0);
227+
228+
// Click on the first icon button to trigger copying the code
229+
iconButtons[0].nativeNode.dispatchEvent(new MouseEvent('click'));
230+
fixture.detectChanges();
231+
expect(clipboardSpy.copy).toHaveBeenCalledWith('const example = "test code";');
232+
});
233+
212234
// TODO(mmalerba): Add test that example-viewer is instantiated.
213235
});
214236

@@ -260,6 +282,10 @@ const FAKE_DOCS: {[key: string]: string} = {
260282
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
261283
</div>
262284
</div>`,
285+
'http://material.angular.io/doc-with-code-block.html': `
286+
<div class="docs-markdown">
287+
<pre><code>const example = "test code";</code></pre>
288+
</div>`,
263289
};
264290

265291
@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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,21 @@
7474
overflow-x: auto;
7575
padding: 20px;
7676
white-space: pre-wrap;
77-
7877
border: solid 1px var(--mat-sys-outline-variant);
7978
border-radius: 12px;
79+
position: relative;
8080

8181
code {
8282
background: transparent;
8383
padding: 0;
8484
font-size: 100%;
8585
}
86+
87+
code-block-copy-button {
88+
position: absolute;
89+
top: 5px;
90+
right: 5px;
91+
}
8692
}
8793

8894
code {

0 commit comments

Comments
 (0)