Skip to content

Commit a70e8c4

Browse files
authored
docs: add copy buttons to markdown code blocks (#33861)
* 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. * docs: render code block copy buttons through markup Emit the copy button as part of the code block markup in the markdown renderer and handle clicks with a single delegated listener in the `DocViewer`, instead of attaching a component to every code block through a portal. This removes the `CodeBlockCopyButton` component.
1 parent 8d9f52c commit a70e8c4

5 files changed

Lines changed: 90 additions & 1 deletion

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ describe('DocViewer', () => {
209209
expect(clipboardSpy.copy).toHaveBeenCalled();
210210
});
211211

212+
it('should copy the code block content when its copy button is clicked', () => {
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+
const copyIcon = docViewer.nativeElement.querySelector('.docs-markdown-copy-button span');
222+
expect(copyIcon).toBeTruthy();
223+
224+
// Click the icon inside the button to verify that the click is delegated to the button.
225+
copyIcon.click();
226+
expect(clipboardSpy.copy).toHaveBeenCalledWith('const example = "test code";');
227+
});
228+
212229
// TODO(mmalerba): Add test that example-viewer is instantiated.
213230
});
214231

@@ -261,6 +278,12 @@ const FAKE_DOCS: {[key: string]: string} = {
261278
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
262279
</div>
263280
</div>`,
281+
'http://material.angular.io/doc-with-code-block.html': `
282+
<div class="docs-markdown">
283+
<pre><code>const example = "test code";</code><button class="docs-markdown-copy-button">
284+
<span>content_copy</span>
285+
</button></pre>
286+
</div>`,
264287
};
265288

266289
@Component({

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
Portal,
1414
CdkPortalOutlet,
1515
} from '@angular/cdk/portal';
16+
import {Clipboard} from '@angular/cdk/clipboard';
1617
import {HttpClient, HttpErrorResponse} from '@angular/common/http';
18+
import {MatSnackBar} from '@angular/material/snack-bar';
1719
import {DomSanitizer} from '@angular/platform-browser';
1820
import {
1921
ApplicationRef,
@@ -68,6 +70,9 @@ class DocFetcher {
6870
`,
6971
imports: [CdkPortalOutlet],
7072
changeDetection: ChangeDetectionStrategy.Eager,
73+
host: {
74+
'(click)': '_handleClick($event)',
75+
},
7176
})
7277
export class DocViewer implements OnDestroy {
7378
private _appRef = inject(ApplicationRef);
@@ -77,6 +82,8 @@ export class DocViewer implements OnDestroy {
7782
private _ngZone = inject(NgZone);
7883
private _domSanitizer = inject(DomSanitizer);
7984
private _docFetcher = inject(DocFetcher);
85+
private _clipboard = inject(Clipboard);
86+
private _snackbar = inject(MatSnackBar);
8087

8188
private _portalHosts: DomPortalOutlet[] = [];
8289
private _documentFetchSubscription: Subscription | undefined;
@@ -215,6 +222,19 @@ export class DocViewer implements OnDestroy {
215222
this._portalHosts = [];
216223
}
217224

225+
/** Copies the content of a code block when its copy button is clicked. */
226+
protected _handleClick(event: MouseEvent) {
227+
const button = (event.target as HTMLElement).closest('.docs-markdown-copy-button');
228+
const code = button?.parentElement?.querySelector('code');
229+
230+
if (code) {
231+
const message = this._clipboard.copy(code.textContent || '')
232+
? 'Copied code snippet'
233+
: 'Failed to copy code snippet';
234+
this._snackbar.open(message, undefined, {duration: 2500});
235+
}
236+
}
237+
218238
ngOnDestroy() {
219239
this._clearLiveExamples();
220240
this._documentFetchSubscription?.unsubscribe();

‎docs/src/styles/_markdown.scss‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,38 @@
7777

7878
border: solid 1px var(--mat-sys-outline-variant);
7979
border-radius: 12px;
80+
position: relative;
8081

8182
code {
8283
background: transparent;
8384
padding: 0;
8485
font-size: 100%;
8586
}
87+
88+
.docs-markdown-copy-button {
89+
position: absolute;
90+
top: 5px;
91+
right: 5px;
92+
display: flex;
93+
align-items: center;
94+
justify-content: center;
95+
width: 40px;
96+
height: 40px;
97+
padding: 0;
98+
border: none;
99+
border-radius: 50%;
100+
background: transparent;
101+
color: var(--mat-sys-on-surface-variant);
102+
cursor: pointer;
103+
104+
&:hover {
105+
background: color-mix(in srgb, var(--mat-sys-on-surface-variant) 8%, transparent);
106+
}
107+
108+
&:focus-visible {
109+
outline: solid 2px var(--mat-sys-primary);
110+
}
111+
}
86112
}
87113

88114
code {

‎tools/markdown-to-html/docs-marked-renderer.mts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,21 @@ export class DocsMarkdownRenderer extends Renderer {
113113
});
114114
}
115115

116+
/**
117+
* Transforms a markdown code block into the corresponding HTML output. Each code block gets a
118+
* button that copies its content. The click is handled by the docs site's `DocViewer`.
119+
*/
116120
code(block: Tokens.Code): string {
117121
const langClass = block.lang ? ` class="language-${block.lang}"` : '';
118-
return `<pre><code${langClass}>${highlightCodeBlock(block.text, block.lang)}</code></pre>`;
122+
const copyButton =
123+
'<button class="docs-markdown-copy-button" type="button" aria-label="Copy code"' +
124+
' title="Copy code to the clipboard">' +
125+
'<span class="material-symbols-outlined" aria-hidden="true">content_copy</span>' +
126+
'</button>';
127+
return (
128+
`<pre><code${langClass}>${highlightCodeBlock(block.text, block.lang)}</code>` +
129+
`${copyButton}</pre>`
130+
);
119131
}
120132

121133
/**

‎tools/markdown-to-html/docs-marked-renderer.spec.mts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ describe('DocsMarkdownRenderer', () => {
108108
);
109109
});
110110

111+
it('adds a copy button to code blocks', () => {
112+
const output = transform('```ts\nconst a = 1;\n```');
113+
expect(output).toContain('<pre><code class="language-ts">');
114+
expect(output).toMatch(
115+
/<\/code><button class="docs-markdown-copy-button" type="button" aria-label="Copy code"/,
116+
);
117+
});
118+
111119
it('does not allow id links with no matching id element', () => {
112120
spyOn(console, 'error');
113121
spyOn(process, 'exit');

0 commit comments

Comments
 (0)