From b0bfc5d1d4e85ec8eeb65e77093dfe80e00fead4 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. 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. --- .../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..9bf58b671608 --- /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 67f02dab336e..ba377d93e9c3 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts @@ -209,6 +209,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. }); @@ -260,6 +282,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 51c8dc31be0d..cf4b4ee77215 100644
--- a/docs/src/styles/_markdown.scss
+++ b/docs/src/styles/_markdown.scss
@@ -74,15 +74,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 {