From 17b22c73a70de20cb75192f7073452e3a3ab1e4d Mon Sep 17 00:00:00 2001 From: MeAkib Date: Wed, 23 Sep 2026 21:17:01 +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 | 44 +++++++++++++++++++ .../app/shared/doc-viewer/doc-viewer.spec.ts | 26 +++++++++++ docs/src/app/shared/doc-viewer/doc-viewer.ts | 31 +++++++++++++ docs/src/styles/_markdown.scss | 7 +++ 4 files changed, 108 insertions(+) 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..a0af5afd4079 --- /dev/null +++ b/docs/src/app/shared/doc-viewer/code-block-copy-button/code-block-copy-button.ts @@ -0,0 +1,44 @@ +/** + * @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'; + +/** Icon button that copies the contents of a code block to the 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 807ea9bf1f2d..1351d4b271fb 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. }); @@ -261,6 +283,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 9d6da8303cd1..c09b42b9d64a 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.ts @@ -40,6 +40,7 @@ import {HeaderLink} from './header-link'; import {DeprecatedFieldComponent} from './deprecated-tooltip'; import {ModuleImportCopyButton} from './module-import-copy-button'; import {AngularAriaBanner} from './angular-aria-banner/angular-aria-banner'; +import {CodeBlockCopyButton} from './code-block-copy-button/code-block-copy-button'; @Service() class DocFetcher { @@ -167,6 +168,9 @@ export class DocViewer implements OnDestroy { // Create icon buttons to copy module import this._createCopyIconForModule(); + // Create icon buttons to copy code blocks + 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. @@ -306,4 +310,31 @@ export class DocViewer implements OnDestroy { this._portalHosts.push(portalHost); } + + /** Adds a button to copy the code to every code block in the document. */ + private _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 aa9184a507a9..b442cdf5cf2b 100644
--- a/docs/src/styles/_markdown.scss
+++ b/docs/src/styles/_markdown.scss
@@ -76,12 +76,19 @@
 
       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 {