mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
26afa313f3
Five more defects in the example viewer, following #70508. The DOM was queried before Angular rendered it. `setCodeLinesVisibility()` walks the rendered lines but ran synchronously on tab change, so it measured the outgoing tab and the incoming file showed in full. The selected tab was also lost when the code block was hidden and reshown, because the recreated tab group had no `[selectedIndex]` while `snippetCode` survived, leaving the strip and the code disagreeing. `expandable` was computed once at startup by counting hidden DOM nodes, so a collapsed tab offered no way to expand it, and recomputing that count on tab change would drop the control whenever the block was expanded, since nothing is hidden then. Both paths now share one rule: a file is expandable when it has a `visibleLinesRange` and either the block is expanded or the range actually hides lines, so a range that covers its whole file still gets no inert control. Array indices were also mixed with 1-based line numbers: the gap check tested `index - 1` for the preceding line, drawing a `...` separator inside contiguous ranges, and the gutter tested `index` while the code tested `index + 1`, shifting every line number by one. Five new specs cover these, using the comma-separated range format the pipeline emits; each fails with its fix reverted. The tab label also moves from 0.8125rem to 0.875rem to match the code beside it.
262 lines
7.9 KiB
TypeScript
262 lines
7.9 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 selectedTabIndex = signal<number>(0);
|
|
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()!}`,
|
|
);
|
|
|
|
this.updateExpandable();
|
|
},
|
|
{injector: this.injector},
|
|
);
|
|
}
|
|
|
|
toggleExampleVisibility(): void {
|
|
this.expanded.update((expanded) => !expanded);
|
|
|
|
this.setCodeLinesVisibility();
|
|
}
|
|
|
|
toggleCodeVisibility(): void {
|
|
const showCode = !this.showCode();
|
|
this.showCode.set(showCode);
|
|
|
|
if (showCode) {
|
|
afterNextRender(() => this.setCodeLinesVisibility(), {injector: this.injector});
|
|
}
|
|
}
|
|
|
|
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.selectedTabIndex.set(index);
|
|
this.snippetCode.set(this.exampleMetadata()?.files[index]);
|
|
|
|
afterNextRender(
|
|
() => {
|
|
this.setCodeLinesVisibility();
|
|
this.updateExpandable();
|
|
},
|
|
{injector: this.injector},
|
|
);
|
|
}
|
|
|
|
// A file is expandable when its range hides lines. While the block is expanded nothing is
|
|
// hidden, so trust the presence of a range instead of the rendered state.
|
|
private updateExpandable(): void {
|
|
this.expandable.set(
|
|
!!this.snippetCode()?.visibleLinesRange &&
|
|
(this.expanded() || this.getHiddenCodeLines().length > 0),
|
|
);
|
|
}
|
|
|
|
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)) {
|
|
appendGapBefore.push(line);
|
|
}
|
|
}
|
|
|
|
for (const [index, lineNumber] of lineNumbers.entries()) {
|
|
if (!linesToDisplay.includes(index + 1)) {
|
|
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}`,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
}
|