diff --git a/adev/shared-docs/components/copy-link-anchor/BUILD.bazel b/adev/shared-docs/components/copy-link-anchor/BUILD.bazel new file mode 100644 index 00000000000..26301f43551 --- /dev/null +++ b/adev/shared-docs/components/copy-link-anchor/BUILD.bazel @@ -0,0 +1,19 @@ +load("//adev/shared-docs:defaults.bzl", "ng_project") + +ng_project( + name = "copy-link-anchor", + srcs = [ + "copy-link-anchor.component.ts", + ], + visibility = [ + "//adev/shared-docs/components:__pkg__", + "//adev/shared-docs/components/viewers:__pkg__", + ], + deps = [ + "//adev:node_modules/@angular/cdk", + "//adev:node_modules/@angular/common", + "//adev:node_modules/@angular/core", + "//adev:node_modules/@angular/material", + "//adev/shared-docs/components/icon", + ], +) diff --git a/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts b/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts new file mode 100644 index 00000000000..d7ff132a3e3 --- /dev/null +++ b/adev/shared-docs/components/copy-link-anchor/copy-link-anchor.component.ts @@ -0,0 +1,66 @@ +/*! + * @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 {ChangeDetectionStrategy, Component, inject, input, signal} from '@angular/core'; +import {Clipboard} from '@angular/cdk/clipboard'; +import {MatTooltip} from '@angular/material/tooltip'; +import {IconComponent} from '../icon/icon.component'; + +export const CONFIRMATION_DISPLAY_TIME_MS = 1000; + +@Component({ + selector: 'docs-copy-link-button', + template: `{{ showCopySuccess() ? 'check' : 'link' }}`, + styles: ` + :host { + cursor: pointer; + padding: 0; + margin-left: 8px; + display: inline-flex; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease; + color: var(--quaternary-contrast); + vertical-align: middle; + } + :host(.docs-copy-link-success) { + color: var(--bright-blue); + } + `, + hostDirectives: [ + { + directive: MatTooltip, + inputs: ['matTooltip'], + }, + ], + host: { + '[ariaLabel]': '"Copy link to " + label()', + '(click)': 'copyLink()', + 'matTooltipPosition': 'above', + '[class.docs-copy-link-success]': 'showCopySuccess()', + }, + imports: [IconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CopyLinkButton { + readonly href = input.required(); + readonly label = input.required(); + + protected readonly showCopySuccess = signal(false); + + private readonly clipboard = inject(Clipboard); + + copyLink(): void { + const link = `${window.location.origin}${this.href()}`; + this.clipboard.copy(link); + this.showCopySuccess.set(true); + setTimeout(() => { + this.showCopySuccess.set(false); + }, CONFIRMATION_DISPLAY_TIME_MS); + } +} diff --git a/adev/shared-docs/components/icon/BUILD.bazel b/adev/shared-docs/components/icon/BUILD.bazel index 1595f9efb34..1fd0ff5a44c 100644 --- a/adev/shared-docs/components/icon/BUILD.bazel +++ b/adev/shared-docs/components/icon/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( ], visibility = [ "//adev/shared-docs/components:__pkg__", + "//adev/shared-docs/components/copy-link-anchor:__pkg__", "//adev/shared-docs/components/copy-source-code-button:__pkg__", "//adev/shared-docs/components/navigation-list:__pkg__", "//adev/shared-docs/components/table-of-contents:__pkg__", diff --git a/adev/shared-docs/components/viewers/BUILD.bazel b/adev/shared-docs/components/viewers/BUILD.bazel index a5f8552943f..4bebf424338 100644 --- a/adev/shared-docs/components/viewers/BUILD.bazel +++ b/adev/shared-docs/components/viewers/BUILD.bazel @@ -25,6 +25,7 @@ ng_project( "//adev:node_modules/@angular/router", "//adev:node_modules/rxjs", "//adev/shared-docs/components/breadcrumb", + "//adev/shared-docs/components/copy-link-anchor", "//adev/shared-docs/components/copy-source-code-button", "//adev/shared-docs/components/icon", "//adev/shared-docs/components/tab-group", @@ -63,6 +64,7 @@ ts_project( "//adev:node_modules/@angular/platform-browser", "//adev:node_modules/@angular/router", "//adev/shared-docs/components/breadcrumb", + "//adev/shared-docs/components/copy-link-anchor", "//adev/shared-docs/components/copy-source-code-button", "//adev/shared-docs/components/icon", "//adev/shared-docs/components/table-of-contents", diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss index bb53477e6b4..9f68dda9912 100644 --- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss +++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.scss @@ -1,5 +1,4 @@ @use '../../../styles/links' as links; -@use '../../../styles/anchor' as anchor; @use '../../../styles/media-queries' as mq; :host { @@ -56,8 +55,6 @@ color: inherit; max-width: 100%; - @include anchor.docs-anchor(); - code { // This can be useful on mobile if the heading contains a long code block overflow: hidden; @@ -65,6 +62,11 @@ vertical-align: middle; } } + + // Show copy link button when hovering the heading + &:hover docs-copy-link-button { + opacity: 1; + } } h1 { diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts index b6be1fe5cd2..7854a0f4987 100644 --- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts +++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.spec.ts @@ -17,8 +17,10 @@ import {IconComponent} from '../../icon/icon.component'; import {Breadcrumb} from '../../breadcrumb/breadcrumb.component'; import {NavigationState} from '../../../services'; import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component'; +import {CopyLinkButton} from '../../copy-link-anchor/copy-link-anchor.component'; import {TableOfContents} from '../../table-of-contents/table-of-contents.component'; import {provideZonelessChangeDetection} from '@angular/core'; +import {Clipboard} from '@angular/cdk/clipboard'; describe('DocViewer', () => { let fixture: ComponentFixture; @@ -70,6 +72,12 @@ describe('DocViewer', () => {

Heading h3

`; + const exampleContentWithDocsAnchor = ` +

+ Test Section +

+ `; + beforeEach(() => { exampleContentSpy = jasmine.createSpyObj('ExampleViewerContentLoader', ['getCodeExampleData']); navigationStateSpy = jasmine.createSpyObj(NavigationState, ['activeNavigationItem']); @@ -87,13 +95,12 @@ describe('DocViewer', () => { }); fixture = TestBed.createComponent(DocViewer); - fixture.detectChanges(); }); - it('should load doc into innerHTML', () => { + it('should load doc into innerHTML', async () => { const fixture = TestBed.createComponent(DocViewer); fixture.componentRef.setInput('docContent', 'hello world'); - fixture.detectChanges(); + await fixture.whenStable(); expect(fixture.nativeElement.innerHTML).toBe('hello world'); }); @@ -101,7 +108,6 @@ describe('DocViewer', () => { it('should instantiate example viewer with only a single file', async () => { const fixture = TestBed.createComponent(DocViewer); fixture.componentRef.setInput('docContent', exampleDocContentWithExampleViewerPlaceholders); - fixture.detectChanges(); await fixture.whenStable(); const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); @@ -125,7 +131,6 @@ describe('DocViewer', () => { 'docContent', exampleDocContentWithExpandedExampleViewerPlaceholders, ); - fixture.detectChanges(); await fixture.whenStable(); const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); @@ -139,7 +144,6 @@ describe('DocViewer', () => { const renderComponentSpy = spyOn(fixture.componentInstance, 'renderComponent' as any); fixture.componentRef.setInput('docContent', exampleContentWithIcons); - fixture.detectChanges(); await fixture.whenStable(); expect(renderComponentSpy).toHaveBeenCalledTimes(2); @@ -165,7 +169,6 @@ describe('DocViewer', () => { const renderComponentSpy = spyOn(fixture.componentInstance, 'renderComponent' as any); fixture.componentRef.setInput('docContent', exampleContentWithBreadcrumbPlaceholder); - fixture.detectChanges(); await fixture.whenStable(); expect(renderComponentSpy).toHaveBeenCalledTimes(1); @@ -176,7 +179,6 @@ describe('DocViewer', () => { const fixture = TestBed.createComponent(DocViewer); fixture.componentRef.setInput('docContent', exampleContentWithCodeSnippet); - fixture.detectChanges(); await fixture.whenStable(); const copySourceCodeButton = fixture.debugElement.query(By.directive(CopySourceCodeButton)); @@ -190,7 +192,6 @@ describe('DocViewer', () => { fixture.componentRef.setInput('docContent', exampleContentWithHeadings); fixture.componentRef.setInput('hasToc', true); - fixture.detectChanges(); await fixture.whenStable(); expect(renderComponentSpy).toHaveBeenCalled(); @@ -203,9 +204,39 @@ describe('DocViewer', () => { fixture.componentRef.setInput('docContent', exampleContentWithHeadings); fixture.componentRef.setInput('hasToc', false); - fixture.detectChanges(); await fixture.whenStable(); expect(renderComponentSpy).not.toHaveBeenCalled(); }); + + it('should setup copy link functionality for docs-anchor elements', async () => { + const fixture = TestBed.createComponent(DocViewer); + fixture.componentRef.setInput('docContent', exampleContentWithDocsAnchor); + + await fixture.whenStable(); + + const copyLinkButton = fixture.debugElement.query(By.directive(CopyLinkButton)); + expect(copyLinkButton).toBeTruthy(); + + const anchor = fixture.nativeElement.querySelector('a.docs-anchor'); + expect(anchor).toBeTruthy(); + + const copyButton = fixture.nativeElement.querySelector('docs-copy-link-button'); + expect(copyButton).toBeTruthy(); + }); + + it('should copy link to clipboard when copy button is clicked', async () => { + const clipboard = TestBed.inject(Clipboard); + const clipboardSpy = spyOn(clipboard, 'copy').and.returnValue(true); + + const fixture = TestBed.createComponent(DocViewer); + fixture.componentRef.setInput('docContent', exampleContentWithDocsAnchor); + + await fixture.whenStable(); + + const copyButton = fixture.nativeElement.querySelector('docs-copy-link-button'); + copyButton.click(); + + expect(clipboardSpy).toHaveBeenCalled(); + }); }); diff --git a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts index df0b073034a..93e95092541 100644 --- a/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts +++ b/adev/shared-docs/components/viewers/docs-viewer/docs-viewer.component.ts @@ -39,6 +39,7 @@ import {TableOfContents} from '../../table-of-contents/table-of-contents.compone import {DomSanitizer} from '@angular/platform-browser'; import {Breadcrumb} from '../../breadcrumb/breadcrumb.component'; import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component'; +import {CopyLinkButton} from '../../copy-link-anchor/copy-link-anchor.component'; import {ExampleViewer} from '../example-viewer/example-viewer.component'; import {TabGroup} from '../../tab-group/tab-group.component'; @@ -120,6 +121,8 @@ export class DocViewer { // In case when content contains static code snippets, then create buttons // responsible for copy source code. this.loadCopySourceCodeButtons(); + // Setup copy link functionality for section anchor links + this.loadCopyLinkAnchors(contentContainer); // In case when content contains tabs, create tabs component and move // content in a tab into tab panel. this.constructTabs(contentContainer); @@ -276,6 +279,22 @@ export class DocViewer { } } + private loadCopyLinkAnchors(element: HTMLElement): void { + const docsAnchors = Array.from(element.querySelectorAll('a.docs-anchor')); + + for (const anchor of docsAnchors) { + const href = anchor.getAttribute('href')!; + const label = anchor.textContent!; + + const copyLinkButtonRef = this.viewContainer.createComponent(CopyLinkButton); + copyLinkButtonRef.setInput('href', href); + copyLinkButtonRef.setInput('label', label); + copyLinkButtonRef.setInput('matTooltip', `Copy link to ${label}`); + + anchor.appendChild(copyLinkButtonRef.location.nativeElement); + } + } + private loadBreadcrumb(element: HTMLElement): void { const breadcrumbPlaceholder = element.querySelector('docs-breadcrumb') as HTMLElement; const activeNavigationItem = this.navigationState.activeNavigationItem(); diff --git a/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts b/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts index e8655b0cca1..f79428d7bdd 100644 --- a/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts +++ b/adev/shared-docs/pipeline/shared/marked/transformations/heading.mts @@ -41,10 +41,11 @@ export function formatHeading( const customId = customIdRegex.exec(anchorLessText)?.[1]; const link = customId ?? getHeaderId(anchorLessText); const label = anchorLessText.replace(/`(.*?)`/g, '$1').replace(customIdRegex, ''); + const normalizedLabel = label.replace(/<\/?code>/g, ''); return ` - ${label} + ${label} `; } diff --git a/adev/shared-docs/services/table-of-contents-loader.service.ts b/adev/shared-docs/services/table-of-contents-loader.service.ts index de84933cd79..a67c8a2fd49 100644 --- a/adev/shared-docs/services/table-of-contents-loader.service.ts +++ b/adev/shared-docs/services/table-of-contents-loader.service.ts @@ -44,7 +44,10 @@ export class TableOfContentsLoader { private getHeadingTitle(heading: HTMLHeadingElement): string { const div: HTMLDivElement = this.document.createElement('div'); + div.innerHTML = heading.innerHTML; + // Remove any existing copy link buttons to avoid including their text in the title + div.querySelectorAll('docs-copy-link-button').forEach((el) => el.remove()); return (div.textContent || '').trim(); } diff --git a/adev/shared-docs/styles/_anchor.scss b/adev/shared-docs/styles/_anchor.scss deleted file mode 100644 index 20985353189..00000000000 --- a/adev/shared-docs/styles/_anchor.scss +++ /dev/null @@ -1,18 +0,0 @@ -@mixin docs-anchor() { - &::after { - content: '\e157'; // codepoint for "link" - font-family: 'Material Symbols Outlined'; - opacity: 0; - margin-left: 8px; - vertical-align: middle; - color: var(--quaternary-contrast); - font-size: clamp(18px, 1.25em, 30px); - transition: opacity 0.3s ease; - } - - &:hover { - &::after { - opacity: 1; - } - } -} diff --git a/adev/shared-docs/styles/_reference.scss b/adev/shared-docs/styles/_reference.scss index 2f0ce55c6e4..1582508caaf 100644 --- a/adev/shared-docs/styles/_reference.scss +++ b/adev/shared-docs/styles/_reference.scss @@ -1,5 +1,3 @@ -@use './anchor' as anchor; - /* Common styles for the API & CLI references */ @mixin reference-common() { .docs-code { @@ -71,7 +69,6 @@ } & > a { - @include anchor.docs-anchor(); color: inherit; } } diff --git a/adev/src/app/features/references/api-items-section/api-items-section.component.scss b/adev/src/app/features/references/api-items-section/api-items-section.component.scss index ee36fae6ccf..ffed34e76aa 100644 --- a/adev/src/app/features/references/api-items-section/api-items-section.component.scss +++ b/adev/src/app/features/references/api-items-section/api-items-section.component.scss @@ -1,5 +1,3 @@ -@use '@angular/docs/styles/anchor' as anchor; - .adev-api-items-section-header { display: flex; @@ -92,6 +90,4 @@ .adev-api-anchor { color: inherit; - - @include anchor.docs-anchor(); }