/*! * @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 {Clipboard} from '@angular/cdk/clipboard'; import {DOCUMENT, NgComponentOutlet, NgTemplateOutlet} from '@angular/common'; import { afterNextRender, Component, computed, ElementRef, inject, Injector, input, signal, Type, } from '@angular/core'; import {MatTab, MatTabGroup} from '@angular/material/tabs'; import {MatTooltip} from '@angular/material/tooltip'; import {ExampleMetadata, Snippet} from '../../../interfaces/index'; import {EXAMPLE_VIEWER_CONTENT_LOADER} from '../../../providers/index'; import {CopySourceCodeButton} from '../../copy-source-code-button/copy-source-code-button.component'; import {IconComponent} from '../../icon/icon.component'; export const CODE_LINE_NUMBER_CLASS_NAME = 'shiki-ln-number'; export const CODE_LINE_CLASS_NAME = 'line'; export const GAP_CODE_LINE_CLASS_NAME = 'gap'; export const HIDDEN_CLASS_NAME = 'hidden'; @Component({ selector: 'docs-example-viewer', imports: [ CopySourceCodeButton, MatTabGroup, MatTab, MatTooltip, IconComponent, NgTemplateOutlet, NgComponentOutlet, ], templateUrl: './example-viewer.component.html', styleUrls: ['./example-viewer.component.scss'], }) export class ExampleViewer { readonly exampleMetadata = input(null, {alias: 'metadata'}); readonly githubUrl = input(null); readonly stackblitzUrl = input(null); private readonly clipboard = inject(Clipboard); private readonly document = inject(DOCUMENT); private readonly injector = inject(Injector); private readonly elementRef = inject(ElementRef); private readonly exampleViewerContentLoader = inject(EXAMPLE_VIEWER_CONTENT_LOADER); private readonly shouldDisplayFullName = computed(() => { const fileExtensions = this.exampleMetadata()?.files.map((file) => this.getFileExtension(file.name)) ?? []; // Display full file names only when exist files with the same extension return new Set(fileExtensions).size !== fileExtensions.length; }); exampleComponent?: Type; readonly expandable = signal(false); readonly expanded = signal(false); readonly snippetCode = signal(undefined); readonly showCode = signal(true); readonly tabs = computed(() => this.exampleMetadata()?.files.map((file) => ({ name: file.title ?? (this.shouldDisplayFullName() ? file.name : this.getFileExtension(file.name)), code: file.sanitizedContent, })), ); async renderExample(): Promise { // Lazy load live example component const path = this.exampleMetadata()?.path; if (path && this.exampleMetadata()?.preview) { this.exampleComponent = await this.exampleViewerContentLoader.loadPreview(path); } this.snippetCode.set(this.exampleMetadata()?.files[0]); if (this.exampleMetadata()?.hideCode) { this.showCode.set(false); } afterNextRender( () => { // Several function below query the DOM directly, we need to wait until the DOM is rendered. this.setCodeLinesVisibility(); this.elementRef.nativeElement.setAttribute( 'id', `example-${this.exampleMetadata()?.id.toString()!}`, ); const lines = this.getHiddenCodeLines(); const lineNumbers = this.getHiddenCodeLineNumbers(); this.expandable.set(lines.length > 0 || lineNumbers.length > 0); }, {injector: this.injector}, ); } toggleExampleVisibility(): void { this.expanded.update((expanded) => !expanded); this.setCodeLinesVisibility(); } copyLink(): void { // Reconstruct the URL using `origin + pathname` so we drop any pre-existing hash. const fullUrl = location.origin + location.pathname + location.search + '#example-' + this.exampleMetadata()?.id; this.clipboard.copy(fullUrl); } protected onTabIndexChange(index: number): void { this.snippetCode.set(this.exampleMetadata()?.files[index]); this.setCodeLinesVisibility(); } private getFileExtension(name: string): string { const segments = name.split('.'); return segments.length ? segments[segments.length - 1].toLocaleUpperCase() : ''; } private setCodeLinesVisibility(): void { this.expanded() ? this.handleExpandedStateForCodeBlock() : this.handleCollapsedStateForCodeBlock(); } private handleExpandedStateForCodeBlock(): void { const lines = this.getHiddenCodeLines(); const lineNumbers = this.getHiddenCodeLineNumbers(); const gapLines = ( Array.from( this.elementRef.nativeElement.querySelectorAll( `.${CODE_LINE_CLASS_NAME}.${GAP_CODE_LINE_CLASS_NAME}`, ), ) ); for (const line of lines) { line.classList.remove(HIDDEN_CLASS_NAME); } for (const lineNumber of lineNumbers) { lineNumber.classList.remove(HIDDEN_CLASS_NAME); } for (const expandLine of gapLines) { expandLine.remove(); } } private handleCollapsedStateForCodeBlock(): void { const visibleLinesRange = this.snippetCode()?.visibleLinesRange; if (!visibleLinesRange) { return; } const linesToDisplay = (visibleLinesRange?.split(',') ?? []).map((line) => Number(line)); const lines = ( Array.from(this.elementRef.nativeElement.querySelectorAll(`.${CODE_LINE_CLASS_NAME}`)) ); const lineNumbers = ( Array.from(this.elementRef.nativeElement.querySelectorAll(`.${CODE_LINE_NUMBER_CLASS_NAME}`)) ); const appendGapBefore = []; for (const [index, line] of lines.entries()) { if (!linesToDisplay.includes(index + 1)) { line.classList.add(HIDDEN_CLASS_NAME); } else if (!linesToDisplay.includes(index - 1)) { appendGapBefore.push(line); } } for (const [index, lineNumber] of lineNumbers.entries()) { if (!linesToDisplay.includes(index)) { lineNumber.classList.add(HIDDEN_CLASS_NAME); } } // Create gap line between visible ranges. For example we would like to display 10-16 and 20-29 lines. // We should display separator, gap between those two scopes. // TODO: we could replace div it with the component, and allow to expand code block after click. for (const [index, element] of appendGapBefore.entries()) { if (index === 0) { continue; } const separator = this.document.createElement('div'); separator.textContent = `...`; separator.classList.add(CODE_LINE_CLASS_NAME); separator.classList.add(GAP_CODE_LINE_CLASS_NAME); element.parentNode?.insertBefore(separator, element); } } private getHiddenCodeLines(): HTMLDivElement[] { return ( Array.from( this.elementRef.nativeElement.querySelectorAll( `.${CODE_LINE_CLASS_NAME}.${HIDDEN_CLASS_NAME}`, ), ) ); } private getHiddenCodeLineNumbers(): HTMLSpanElement[] { return ( Array.from( this.elementRef.nativeElement.querySelectorAll( `.${CODE_LINE_NUMBER_CLASS_NAME}.${HIDDEN_CLASS_NAME}`, ), ) ); } }