/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
docs/src/app/shared/doc-viewer/doc-viewer.ts
309 строк
11 KB
Kristiyan Kostadinov
build: switch docs to service decorator
25 апр 2026, 23:10
25 апр 2026, 23:10
7ac0542
Код
Авторство
О чём код?
/** * @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 { ComponentType, ComponentPortal, DomPortalOutlet, Portal, CdkPortalOutlet, } from '@angular/cdk/portal'; import {HttpClient, HttpErrorResponse} from '@angular/common/http'; import {DomSanitizer} from '@angular/platform-browser'; import { ApplicationRef, Component, ElementRef, EventEmitter, Service, Injector, Input, NgZone, OnDestroy, Output, SecurityContext, ViewContainerRef, input, inject, Type, ChangeDetectionStrategy, } from '@angular/core'; import {Observable, Subscription} from 'rxjs'; import {shareReplay, take, tap} from 'rxjs/operators'; 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 {AngularAriaBanner} from './angular-aria-banner/angular-aria-banner'; @Service() class DocFetcher { private _http = inject(HttpClient); private _cache: Record<string, Observable<string>> = {}; fetchDocument(url: string): Observable<string> { if (this._cache[url]) { return this._cache[url]; } const stream = this._http.get(url, {responseType: 'text'}).pipe(shareReplay(1)); return stream.pipe(tap(() => (this._cache[url] = stream))); } } @Component({ selector: 'doc-viewer', template: ` @if (portal) { <ng-template [cdkPortalOutlet]="portal" /> } @else { Loading document... } `, imports: [CdkPortalOutlet], changeDetection: ChangeDetectionStrategy.Eager, }) export class DocViewer implements OnDestroy { private _appRef = inject(ApplicationRef); _elementRef = inject(ElementRef); private _injector = inject(Injector); private _viewContainerRef = inject(ViewContainerRef); private _ngZone = inject(NgZone); private _domSanitizer = inject(DomSanitizer); private _docFetcher = inject(DocFetcher); private _portalHosts: DomPortalOutlet[] = []; private _documentFetchSubscription: Subscription | undefined; protected portal: Portal<any> | undefined; readonly name = input<string>(); readonly packageName = input<string>(); /** The document to display, either as a URL to a markdown file or a component to create. */ @Input() set document(document: string | ComponentType<any> | undefined) { if (typeof document === 'string') { this._fetchDocument(document); } else if (document) { this.portal = new ComponentPortal(document); // 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. // tslint:disable-next-line:no-zone-dependencies this._ngZone.onStable .pipe(take(1)) .subscribe(() => this.contentRendered.next(this._elementRef.nativeElement)); } } @Output() contentRendered = new EventEmitter<HTMLElement>(); /** The document text. It should not be HTML encoded. */ textContent = ''; private static _initExampleViewer( exampleViewerComponent: ExampleViewer, example: string, file: string | null, region: string | null, ) { exampleViewerComponent.example = example; if (file) { // if the html div has field `file` then it should be in compact view to show the code // snippet exampleViewerComponent.view.set('snippet'); exampleViewerComponent.showCompactToggle.set(true); exampleViewerComponent.file.set(file); if (region) { // `region` should only exist when `file` exists but not vice versa // It is valid for embedded example snippets to show the whole file (esp short files) exampleViewerComponent.region.set(region); } } else { // otherwise it is an embedded demo exampleViewerComponent.view.set('demo'); } } /** Fetch a document by URL. */ private _fetchDocument(url: string) { this._documentFetchSubscription?.unsubscribe(); this._documentFetchSubscription = this._docFetcher.fetchDocument(url).subscribe( document => this._updateDocument(document), error => this._showError(url, error), ); } /** * Updates the displayed document. * @param rawDocument The raw document content to show. */ private _updateDocument(rawDocument: string) { // Replace all relative fragment URLs with absolute fragment URLs. e.g. "#my-section" becomes // "/components/button/api#my-section". This is necessary because otherwise these fragment // links would redirect to "/#my-section". rawDocument = rawDocument.replace(/href="#([^"]*)"/g, (_m: string, fragmentUrl: string) => { const absoluteUrl = `${location.pathname}#${fragmentUrl}`; return `href="${this._domSanitizer.sanitize(SecurityContext.URL, absoluteUrl)}"`; }); this._elementRef.nativeElement.innerHTML = rawDocument; this.textContent = this._elementRef.nativeElement.textContent; this._loadComponents('material-docs-example', ExampleViewer); this._loadComponents('header-link', HeaderLink); // Inject Angular Aria banner for specific CDK components this._injectAngularAriaBanner(); // Create tooltips for the deprecated fields this._createTooltipsForDeprecated(); // Create icon buttons to copy module import this._createCopyIconForModule(); // 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. // tslint:disable-next-line:no-zone-dependencies this._ngZone.onStable .pipe(take(1)) .subscribe(() => this.contentRendered.next(this._elementRef.nativeElement)); } /** Show an error that occurred when fetching a document. */ private _showError(url: string, error: HttpErrorResponse) { console.error(error); this._elementRef.nativeElement.innerText = `Failed to load document: ${url}. Error: ${error.statusText}`; } /** Instantiate a ExampleViewer for each example. */ private _loadComponents(componentName: string, componentClass: Type<ExampleViewer | HeaderLink>) { const exampleElements = this._elementRef.nativeElement.querySelectorAll(`[${componentName}]`); [...exampleElements].forEach((element: Element) => { const example = element.getAttribute(componentName); const region = element.getAttribute('region'); const file = element.getAttribute('file'); const portalHost = new DomPortalOutlet(element, this._appRef, this._injector); const examplePortal = new ComponentPortal(componentClass, this._viewContainerRef); const exampleViewer = portalHost.attach(examplePortal); const exampleViewerComponent = exampleViewer.instance; if (example !== null) { if (componentClass === ExampleViewer) { DocViewer._initExampleViewer( exampleViewerComponent as ExampleViewer, example, file, region, ); } else { (exampleViewerComponent as HeaderLink).example.set(example); } } this._portalHosts.push(portalHost); }); } private _clearLiveExamples() { this._portalHosts.forEach(h => h.dispose()); this._portalHosts = []; } ngOnDestroy() { this._clearLiveExamples(); this._documentFetchSubscription?.unsubscribe(); } _createTooltipsForDeprecated() { // all of the deprecated symbols end with `deprecated-marker` // class name on their element. // for example: // <div class="docs-api-deprecated-marker">Deprecated</div>, // these can vary for each deprecated symbols such for class, interface, // type alias, constants or properties: // .docs-api-class-interface-marker, docs-api-type-alias-deprecated-marker // .docs-api-constant-deprecated-marker, .some-more // so instead of manually writing each deprecated class, we just query // elements that ends with `deprecated-marker` in their class name. const deprecatedElements = this._elementRef.nativeElement.querySelectorAll( `[class$=deprecated-marker]`, ); [...deprecatedElements].forEach((element: Element) => { // the deprecation message, it will include alternative to deprecated item // and breaking change if there is one included. const deprecationTitle = element.getAttribute('deprecated-message'); const elementPortalOutlet = new DomPortalOutlet(element, this._appRef, this._injector); const tooltipPortal = new ComponentPortal(DeprecatedFieldComponent, this._viewContainerRef); const tooltipOutlet = elementPortalOutlet.attach(tooltipPortal); if (deprecationTitle) { tooltipOutlet.instance.message = deprecationTitle; } this._portalHosts.push(elementPortalOutlet); }); } _createCopyIconForModule() { // every module import element will be marked with docs-api-module-import-button attribute const moduleImportElements = this._elementRef.nativeElement.querySelectorAll( '[data-docs-api-module-import-button]', ); [...moduleImportElements].forEach((element: HTMLElement) => { // get the module import path stored in the attribute const moduleImport = element.getAttribute('data-docs-api-module-import-button'); const elementPortalOutlet = new DomPortalOutlet(element, this._appRef, this._injector); const moduleImportPortal = new ComponentPortal( ModuleImportCopyButton, this._viewContainerRef, ); const moduleImportOutlet = elementPortalOutlet.attach(moduleImportPortal); if (moduleImport) { moduleImportOutlet.instance.import = moduleImport; } this._portalHosts.push(elementPortalOutlet); }); } /** * Injects the Angular Aria migration banner for specific CDK components. */ private _injectAngularAriaBanner() { const componentName = this.name(); const packageName = this.packageName(); if ( !componentName || packageName !== 'cdk' || !['listbox', 'tree', 'accordion', 'menu'].includes(componentName.toLowerCase()) ) { return; } // Create a container div for the banner at the beginning of the document const bannerContainer = document.createElement('div'); bannerContainer.setAttribute('angular-aria-banner', ''); bannerContainer.setAttribute('componentName', componentName); // Insert the banner at the beginning of the document content this._elementRef.nativeElement.prepend(bannerContainer); // Create and attach the banner component const portalHost = new DomPortalOutlet(bannerContainer, this._appRef, this._injector); const bannerPortal = new ComponentPortal(AngularAriaBanner, this._viewContainerRef); const bannerComponent = portalHost.attach(bannerPortal); bannerComponent.instance.componentName = componentName; this._portalHosts.push(portalHost); } }