🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
import { Component, Input, computed, signal, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { SyntaxHighlighterService } from '../../services/syntax-highlighter.service';
|
|
import { CodeThemeService, CodeTheme } from '../../services/theme.service';
|
|
import { CopyButtonDirective } from '../../directives/copy-button.directive';
|
|
|
|
export type CodeBlockVariant = 'default' | 'minimal' | 'bordered';
|
|
|
|
@Component({
|
|
selector: 'ui-code-block',
|
|
standalone: true,
|
|
imports: [CommonModule, CopyButtonDirective],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
encapsulation: ViewEncapsulation.None,
|
|
template: `
|
|
<div [class]="containerClasses()">
|
|
@if (copyable) {
|
|
<button
|
|
type="button"
|
|
class="code-block__copy-btn"
|
|
[uiCopyButton]="code"
|
|
[attr.aria-label]="'Copy code to clipboard'"
|
|
title="Copy code">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
|
</svg>
|
|
</button>
|
|
}
|
|
|
|
<pre [class]="preClasses()">
|
|
@if (showLineNumbers && lineCount() > 1) {
|
|
<div class="code-block__line-numbers" [attr.aria-hidden]="true">
|
|
@for (lineNum of lineNumbers(); track lineNum) {
|
|
<div
|
|
class="code-block__line-number"
|
|
[class.code-block__line-number--highlighted]="isLineHighlighted(lineNum)">
|
|
{{ lineNum }}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<code
|
|
[class]="codeClasses()"
|
|
[innerHTML]="highlightedCode()">
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
`,
|
|
styleUrl: './code-block.component.scss'
|
|
})
|
|
export class CodeBlockComponent implements OnInit, OnChanges {
|
|
@Input() code = '';
|
|
@Input() language = '';
|
|
@Input() theme: CodeTheme | null = null;
|
|
@Input() variant: CodeBlockVariant = 'default';
|
|
@Input() showLineNumbers = true;
|
|
@Input() copyable = true;
|
|
@Input() highlightLines: (number | string)[] = [];
|
|
@Input() startLineNumber = 1;
|
|
@Input() wrap = false;
|
|
|
|
constructor(
|
|
private syntaxHighlighter: SyntaxHighlighterService,
|
|
private themeService: CodeThemeService
|
|
) {}
|
|
|
|
readonly finalLanguage = computed(() => {
|
|
if (this.language) {
|
|
return this.language;
|
|
}
|
|
return this.syntaxHighlighter.detectLanguage(this.code);
|
|
});
|
|
|
|
readonly highlightedCode = signal('');
|
|
|
|
private async updateHighlightedCode() {
|
|
if (!this.code) {
|
|
this.highlightedCode.set('');
|
|
return;
|
|
}
|
|
|
|
const lang = this.finalLanguage();
|
|
try {
|
|
const highlighted = await this.syntaxHighlighter.highlight(this.code, {
|
|
language: lang,
|
|
lineNumbers: this.showLineNumbers,
|
|
highlightLines: this.highlightLines
|
|
});
|
|
this.highlightedCode.set(highlighted);
|
|
} catch (error) {
|
|
console.warn('Failed to highlight code:', error);
|
|
this.highlightedCode.set(this.escapeHtml(this.code));
|
|
}
|
|
}
|
|
|
|
readonly lineCount = computed(() => {
|
|
if (!this.code) return 0;
|
|
|
|
const lines = this.code.split('\n');
|
|
// Remove the last empty line if the code ends with a newline
|
|
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
return lines.length - 1;
|
|
}
|
|
return lines.length;
|
|
});
|
|
|
|
readonly lineNumbers = computed(() => {
|
|
const count = this.lineCount();
|
|
return Array.from({ length: count }, (_, i) => i + this.startLineNumber);
|
|
});
|
|
|
|
readonly currentTheme = computed(() => {
|
|
return this.theme || this.themeService.theme();
|
|
});
|
|
|
|
readonly containerClasses = computed(() => {
|
|
const classes = ['code-block'];
|
|
classes.push(`code-block--${this.variant}`);
|
|
classes.push(`code-theme-${this.currentTheme()}`);
|
|
|
|
if (this.wrap) classes.push('code-block--wrap');
|
|
if (this.copyable) classes.push('code-block--copyable');
|
|
|
|
return classes.join(' ');
|
|
});
|
|
|
|
readonly preClasses = computed(() => {
|
|
const classes = ['code-block__pre'];
|
|
if (this.showLineNumbers && this.lineCount() > 1) {
|
|
classes.push('code-block__pre--with-line-numbers');
|
|
}
|
|
return classes.join(' ');
|
|
});
|
|
|
|
readonly codeClasses = computed(() => {
|
|
const classes = ['code-block__code'];
|
|
classes.push(`language-${this.finalLanguage()}`);
|
|
return classes.join(' ');
|
|
});
|
|
|
|
isLineHighlighted(lineNumber: number): boolean {
|
|
return this.highlightLines.some(highlight => {
|
|
if (typeof highlight === 'number') {
|
|
return highlight === lineNumber;
|
|
}
|
|
|
|
// Handle range format like "5-10"
|
|
const range = highlight.toString().split('-');
|
|
if (range.length === 2) {
|
|
const start = parseInt(range[0], 10);
|
|
const end = parseInt(range[1], 10);
|
|
return lineNumber >= start && lineNumber <= end;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.updateHighlightedCode();
|
|
}
|
|
|
|
ngOnChanges(changes: SimpleChanges): void {
|
|
if (changes['code'] || changes['language']) {
|
|
this.updateHighlightedCode();
|
|
}
|
|
}
|
|
|
|
private escapeHtml(text: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
} |