mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
/*!
|
|
* @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<ExampleMetadata | null>(null, {alias: 'metadata'});
|
|
readonly githubUrl = input<string | null>(null);
|
|
readonly stackblitzUrl = input<string | null>(null);
|
|
|
|
private readonly clipboard = inject(Clipboard);
|
|
private readonly document = inject(DOCUMENT);
|
|
private readonly injector = inject(Injector);
|
|
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
|
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<unknown>;
|
|
|
|
readonly expandable = signal<boolean>(false);
|
|
readonly expanded = signal<boolean>(false);
|
|
readonly snippetCode = signal<Snippet | undefined>(undefined);
|
|
readonly showCode = signal<boolean>(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<void> {
|
|
// 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 = <HTMLDivElement[]>(
|
|
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 = <HTMLDivElement[]>(
|
|
Array.from(this.elementRef.nativeElement.querySelectorAll(`.${CODE_LINE_CLASS_NAME}`))
|
|
);
|
|
const lineNumbers = <HTMLSpanElement[]>(
|
|
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 <HTMLDivElement[]>(
|
|
Array.from(
|
|
this.elementRef.nativeElement.querySelectorAll(
|
|
`.${CODE_LINE_CLASS_NAME}.${HIDDEN_CLASS_NAME}`,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
private getHiddenCodeLineNumbers(): HTMLSpanElement[] {
|
|
return <HTMLSpanElement[]>(
|
|
Array.from(
|
|
this.elementRef.nativeElement.querySelectorAll(
|
|
`.${CODE_LINE_NUMBER_CLASS_NAME}.${HIDDEN_CLASS_NAME}`,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
}
|