Files
Kam 6f5406c678 fix(docs-infra): correct example viewer tab state and line numbering
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.

(cherry picked from commit 26afa313f3)
2026-09-04 07:26:29 -07:00

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}`,
),
)
);
}
}