Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: `
<button
mat-icon-button
aria-label="Copy code"
matTooltip="Copy code to the clipboard"
(click)="copy()">
<mat-icon>content_copy</mat-icon>
</button>
`,
})
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});
}
}
26 changes: 26 additions & 0 deletions docs/src/app/shared/doc-viewer/doc-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});

Expand Down Expand Up @@ -261,6 +283,10 @@ const FAKE_DOCS: {[key: string]: string} = {
data-docs-api-module-import-button="import {MatIconModule} from '@angular/material/icon';">
</div>
</div>`,
'http://material.angular.io/doc-with-code-block.html': `
<div class="docs-markdown">
<pre><code>const example = "test code";</code></pre>
</div>`,
};

@Component({
Expand Down
31 changes: 31 additions & 0 deletions docs/src/app/shared/doc-viewer/doc-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to use portals for this, couldn't we do it through the markup?

// Query all <pre> tags that contain <code> 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);
});
}
}
7 changes: 7 additions & 0 deletions docs/src/styles/_markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading