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 {