From fbd03645f151101b00571ea74936380d5e318049 Mon Sep 17 00:00:00 2001 From: MeAkib Date: Tue, 7 Oct 2025 00:11:58 +0600 Subject: [PATCH] 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. --- .../code-block-copy-button.ts | 39 +++++++++++++++++++ .../app/shared/doc-viewer/doc-viewer.spec.ts | 26 +++++++++++++ docs/src/app/shared/doc-viewer/doc-viewer.ts | 30 ++++++++++++++ docs/src/styles/_markdown.scss | 8 +++- 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 docs/src/app/shared/doc-viewer/code-block-copy-button/code-block-copy-button.ts diff --git a/docs/src/app/shared/doc-viewer/code-block-copy-button/code-block-copy-button.ts b/docs/src/app/shared/doc-viewer/code-block-copy-button/code-block-copy-button.ts new file mode 100644 index 000000000000..e2fac2de686d --- /dev/null +++ b/docs/src/app/shared/doc-viewer/code-block-copy-button/code-block-copy-button.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, inject} from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {MatTooltip} from '@angular/material/tooltip'; +import {Clipboard} from '@angular/cdk/clipboard'; + +@Component({ + selector: 'code-block-copy-button', + imports: [MatIconButton, MatIcon, MatTooltip], + template: ` + + `, +}) +export class CodeBlockCopyButton { + private _clipboard = inject(Clipboard); + private _snackbar = inject(MatSnackBar); + + /** Code snippet that will be copied */ + code = ''; + + copy(): void { + const message = this._clipboard.copy(this.code) + ? 'Copied code snippet' + : 'Failed to copy code snippet'; + + this._snackbar.open(message, undefined, {duration: 2500}); + } +} diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts index 2328ec7f85f5..a5951cab926b 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts @@ -211,6 +211,28 @@ describe('DocViewer', () => { expect(clipboardSpy.copy).toHaveBeenCalled(); }); + it('should show copy icon button for code blocks', () => { + const fixture = TestBed.createComponent(DocViewerTestComponent); + fixture.componentInstance.documentUrl = `http://material.angular.io/doc-with-code-block.html`; + fixture.detectChanges(); + + const url = fixture.componentInstance.documentUrl; + http.expectOne(url).flush(FAKE_DOCS[url]); + + const docViewer = fixture.debugElement.query(By.directive(DocViewer)); + expect(docViewer).not.toBeNull(); + + // Query all copy buttons within code blocks + const iconButtons = fixture.debugElement.queryAll(By.directive(MatIconButton)); + // At least one icon button for copying code should exist + expect(iconButtons.length).toBeGreaterThan(0); + + // Click on the first icon button to trigger copying the code + iconButtons[0].nativeNode.dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(clipboardSpy.copy).toHaveBeenCalledWith('const example = "test code";'); + }); + // TODO(mmalerba): Add test that example-viewer is instantiated. }); @@ -262,6 +284,10 @@ const FAKE_DOCS: {[key: string]: string} = { data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';"> `, + 'http://material.angular.io/doc-with-code-block.html': ` +
+
const example = "test code";
+
`, }; @Component({ diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.ts b/docs/src/app/shared/doc-viewer/doc-viewer.ts index 93eb369c1227..bc0223cff9b9 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.ts @@ -38,6 +38,7 @@ import {ExampleViewer} from '../example-viewer/example-viewer'; import {HeaderLink} from './header-link'; import {DeprecatedFieldComponent} from './deprecated-tooltip'; import {ModuleImportCopyButton} from './module-import-copy-button'; +import {CodeBlockCopyButton} from './code-block-copy-button/code-block-copy-button'; @Injectable({providedIn: 'root'}) class DocFetcher { @@ -160,6 +161,9 @@ export class DocViewer implements OnDestroy { // Create icon buttons to copy module import this._createCopyIconForModule(); + // Create icon button for code block + this._createCopyButtonsForCodeBlocks(); + // Resolving and creating components dynamically in Angular happens synchronously, but since // we want to emit the output if the components are actually rendered completely, we wait // until the Angular zone becomes stable. @@ -267,4 +271,30 @@ export class DocViewer implements OnDestroy { this._portalHosts.push(elementPortalOutlet); }); } + + _createCopyButtonsForCodeBlocks() { + // Query all
 tags that contain  elements (markdown code blocks)
+    const codeBlockElements = this._elementRef.nativeElement.querySelectorAll(
+      '.docs-markdown pre:has(code)',
+    );
+
+    [...codeBlockElements].forEach((element: HTMLElement) => {
+      // Extract the text content from the code block
+      const codeElement = element.querySelector('code');
+      const codeSnippet = codeElement?.textContent || '';
+
+      const elementPortalOutlet = new DomPortalOutlet(element, this._appRef, this._injector);
+      const codeBlockCopyButtonPortal = new ComponentPortal(
+        CodeBlockCopyButton,
+        this._viewContainerRef,
+      );
+      const codeBlockCopyButtonOutlet = elementPortalOutlet.attach(codeBlockCopyButtonPortal);
+
+      if (codeSnippet) {
+        codeBlockCopyButtonOutlet.instance.code = codeSnippet;
+      }
+
+      this._portalHosts.push(elementPortalOutlet);
+    });
+  }
 }
diff --git a/docs/src/styles/_markdown.scss b/docs/src/styles/_markdown.scss
index 99c056889a22..16a3af7dd86a 100644
--- a/docs/src/styles/_markdown.scss
+++ b/docs/src/styles/_markdown.scss
@@ -72,15 +72,21 @@
       overflow-x: auto;
       padding: 20px;
       white-space: pre-wrap;
-
       border: solid 1px var(--mat-sys-outline-variant);
       border-radius: 12px;
+      position: relative;
 
       code {
         background: transparent;
         padding: 0;
         font-size: 100%;
       }
+
+      code-block-copy-button {
+        position: absolute;
+        top: 5px;
+        right: 5px;
+      }
     }
 
     code {