Add comprehensive library expansion with new components and demos
- Add new libraries: ui-accessibility, ui-animations, ui-backgrounds, ui-code-display, ui-data-utils, ui-font-manager, hcl-studio - Add extensive layout components: gallery-grid, infinite-scroll-container, kanban-board, masonry, split-view, sticky-layout - Add comprehensive demo components for all new features - Update project configuration and dependencies - Expand component exports and routing structure - Add UI landing pages planning document 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
// Code block component styles with design token integration
|
||||
.code-block {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-family: 'Roboto Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: $base-typography-font-size-sm;
|
||||
background: var(--code-bg, #{$semantic-color-surface-primary});
|
||||
|
||||
// Variant styles
|
||||
&--default {
|
||||
border: $semantic-border-card-width $semantic-border-card-style var(--code-border, #{$semantic-color-border-secondary});
|
||||
border-radius: $semantic-border-card-radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--minimal {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&--bordered {
|
||||
border-left: 4px solid var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
padding-left: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
// Wrapping variant
|
||||
&--wrap {
|
||||
.code-block__code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
// Copyable variant
|
||||
&--copyable {
|
||||
&:hover .code-block__copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block__copy-btn {
|
||||
position: absolute;
|
||||
top: $semantic-spacing-component-sm;
|
||||
right: $semantic-spacing-component-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--code-surface, #{$semantic-color-surface-secondary});
|
||||
color: var(--code-text, #{$semantic-color-text-secondary});
|
||||
border-radius: $semantic-border-button-radius;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-duration-fast $semantic-easing-standard;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: var(--code-border, #{$semantic-color-surface-elevated});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: $semantic-border-focus-width solid var(--code-keyword, #{$semantic-color-focus});
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block__pre {
|
||||
margin: 0;
|
||||
padding: $semantic-spacing-component-md;
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: $base-typography-line-height-relaxed;
|
||||
display: flex;
|
||||
|
||||
&--with-line-numbers {
|
||||
.code-block__code {
|
||||
padding-left: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block--minimal & {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-block--bordered & {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Line numbers
|
||||
.code-block__line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: $semantic-spacing-component-md;
|
||||
border-right: $semantic-border-separator-width $semantic-border-separator-style var(--code-border, #{$semantic-color-border-subtle});
|
||||
user-select: none;
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-block__line-number {
|
||||
color: var(--code-line-number, #{$semantic-color-text-tertiary});
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0 $semantic-spacing-component-xs;
|
||||
|
||||
&--highlighted {
|
||||
background: var(--code-line-highlight, #{$semantic-color-container-primary});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
}
|
||||
|
||||
// Code content
|
||||
.code-block__code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre;
|
||||
overflow: visible;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
// Syntax highlighting styles
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--code-comment, #{$semantic-color-text-tertiary});
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.keyword,
|
||||
.token.control,
|
||||
.token.directive,
|
||||
.token.unit {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
font-weight: $base-typography-font-weight-medium;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.attr-value {
|
||||
color: var(--code-string, #{$semantic-color-success});
|
||||
}
|
||||
|
||||
.token.number {
|
||||
color: var(--code-number, #{$semantic-color-warning});
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: var(--code-function, #{$semantic-color-interactive-primary});
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--code-operator, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: var(--code-punctuation, #{$semantic-color-text-secondary});
|
||||
}
|
||||
|
||||
.token.variable {
|
||||
color: var(--code-variable, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.constant {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.attr-name {
|
||||
color: var(--code-function, #{$semantic-color-interactive-primary});
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.tag {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
}
|
||||
|
||||
.token.important {
|
||||
font-weight: $base-typography-font-weight-bold;
|
||||
}
|
||||
|
||||
.token.bold {
|
||||
font-weight: $base-typography-font-weight-bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.code-block__pre {
|
||||
padding: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
.code-block__copy-btn {
|
||||
top: $semantic-spacing-component-xs;
|
||||
right: $semantic-spacing-component-xs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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) {
|
||||
<span
|
||||
class="code-block__line-number"
|
||||
[class.code-block__line-number--highlighted]="isLineHighlighted(lineNum)">
|
||||
{{ lineNum }}
|
||||
</span>
|
||||
}
|
||||
</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(() => {
|
||||
return this.code ? this.code.split('\n').length : 0;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
// Code snippet component styles with design token integration
|
||||
.code-snippet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: $semantic-border-card-radius;
|
||||
border: $semantic-border-card-width $semantic-border-card-style var(--code-border, #{$semantic-color-border-secondary});
|
||||
background: var(--code-bg, #{$semantic-color-surface-primary});
|
||||
overflow: hidden;
|
||||
font-family: 'Roboto Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
// Size variants
|
||||
&--sm {
|
||||
font-size: $base-typography-font-size-xs;
|
||||
|
||||
.code-snippet__header {
|
||||
padding: $semantic-spacing-component-padding-xs $semantic-spacing-component-padding-sm;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
}
|
||||
|
||||
.code-snippet__content {
|
||||
padding: $semantic-spacing-component-padding-xs $semantic-spacing-component-padding-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&--md {
|
||||
font-size: $base-typography-font-size-sm;
|
||||
|
||||
.code-snippet__header {
|
||||
padding: $semantic-spacing-component-padding-sm $semantic-spacing-component-padding-md;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
.code-snippet__content {
|
||||
padding: $semantic-spacing-component-padding-sm $semantic-spacing-component-padding-md;
|
||||
}
|
||||
}
|
||||
|
||||
&--lg {
|
||||
font-size: $base-typography-font-size-md;
|
||||
|
||||
.code-snippet__header {
|
||||
padding: $semantic-spacing-component-padding-md $semantic-spacing-component-padding-lg;
|
||||
gap: $semantic-spacing-component-md;
|
||||
}
|
||||
|
||||
.code-snippet__content {
|
||||
padding: $semantic-spacing-component-padding-md $semantic-spacing-component-padding-lg;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping variant
|
||||
&--wrap {
|
||||
.code-snippet__code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
// Constrained height variant
|
||||
&--constrained {
|
||||
.code-snippet__content {
|
||||
max-height: var(--max-height);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: $semantic-spacing-component-xl;
|
||||
background: linear-gradient(to bottom, transparent, var(--code-bg, #{$semantic-color-surface-primary}));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header section
|
||||
.code-snippet__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--code-surface, #{$semantic-color-surface-secondary});
|
||||
border-bottom: $semantic-border-divider-width $semantic-border-divider-style var(--code-border, #{$semantic-color-border-secondary});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.code-snippet__title {
|
||||
font-weight: $base-typography-font-weight-medium;
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.code-snippet__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-sm;
|
||||
}
|
||||
|
||||
.code-snippet__language-badge {
|
||||
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||
background: var(--code-keyword, #{$semantic-color-interactive-primary});
|
||||
color: white;
|
||||
border-radius: $semantic-border-input-radius;
|
||||
font-size: $base-typography-font-size-xs;
|
||||
font-weight: $base-typography-font-weight-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.code-snippet__copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--code-text, #{$semantic-color-text-secondary});
|
||||
border-radius: $semantic-border-button-radius;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-duration-fast $semantic-easing-standard;
|
||||
|
||||
&:hover {
|
||||
background: var(--code-surface, #{$semantic-color-surface-elevated});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: $semantic-border-focus-width solid var(--code-keyword, #{$semantic-color-focus});
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Content area
|
||||
.code-snippet__content {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
background: var(--code-bg, #{$semantic-color-surface-primary});
|
||||
}
|
||||
|
||||
.code-snippet__pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: $base-typography-line-height-relaxed;
|
||||
display: flex;
|
||||
|
||||
&--with-line-numbers {
|
||||
.code-snippet__code {
|
||||
padding-left: $semantic-spacing-component-md;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Line numbers
|
||||
.code-snippet__line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: $semantic-spacing-component-md;
|
||||
border-right: $semantic-border-separator-width $semantic-border-separator-style var(--code-border, #{$semantic-color-border-subtle});
|
||||
user-select: none;
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.code-snippet__line-number {
|
||||
color: var(--code-line-number, #{$semantic-color-text-tertiary});
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0 $semantic-spacing-component-xs;
|
||||
|
||||
&--highlighted {
|
||||
background: var(--code-line-highlight, #{$semantic-color-container-primary});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
}
|
||||
|
||||
// Code content
|
||||
.code-snippet__code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre;
|
||||
overflow: visible;
|
||||
flex: 1;
|
||||
|
||||
// Syntax highlighting styles
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--code-comment, #{$semantic-color-text-tertiary});
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.keyword,
|
||||
.token.control,
|
||||
.token.directive,
|
||||
.token.unit {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
font-weight: $base-typography-font-weight-medium;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.attr-value {
|
||||
color: var(--code-string, #{$semantic-color-success});
|
||||
}
|
||||
|
||||
.token.number {
|
||||
color: var(--code-number, #{$semantic-color-warning});
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: var(--code-function, #{$semantic-color-interactive-primary});
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--code-operator, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: var(--code-punctuation, #{$semantic-color-text-secondary});
|
||||
}
|
||||
|
||||
.token.variable {
|
||||
color: var(--code-variable, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.constant {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.attr-name {
|
||||
color: var(--code-function, #{$semantic-color-interactive-primary});
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.tag {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
}
|
||||
}
|
||||
|
||||
// Expand section
|
||||
.code-snippet__expand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: $semantic-spacing-component-sm;
|
||||
background: var(--code-surface, #{$semantic-color-surface-secondary});
|
||||
border-top: $semantic-border-separator-width $semantic-border-separator-style var(--code-border, #{$semantic-color-border-secondary});
|
||||
}
|
||||
|
||||
.code-snippet__expand-btn {
|
||||
padding: $semantic-spacing-component-xs $semantic-spacing-component-md;
|
||||
border: $semantic-border-button-width $semantic-border-button-style var(--code-border, #{$semantic-color-border-primary});
|
||||
border-radius: $semantic-border-button-radius;
|
||||
background: var(--code-bg, #{$semantic-color-surface-primary});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
font-size: $base-typography-font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all $semantic-duration-fast $semantic-easing-standard;
|
||||
|
||||
&:hover {
|
||||
background: var(--code-surface, #{$semantic-color-surface-elevated});
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: $semantic-border-focus-width solid var(--code-keyword, #{$semantic-color-focus});
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme-specific overrides
|
||||
.code-theme-github-light {
|
||||
--code-bg: #{$semantic-color-surface-primary};
|
||||
--code-surface: #{$semantic-color-surface-secondary};
|
||||
--code-text: #{$semantic-color-text-primary};
|
||||
--code-comment: #{$semantic-color-text-tertiary};
|
||||
--code-keyword: #{$semantic-color-brand-primary};
|
||||
--code-string: #{$semantic-color-success};
|
||||
--code-number: #{$semantic-color-warning};
|
||||
--code-function: #{$semantic-color-interactive-primary};
|
||||
--code-operator: #{$semantic-color-text-primary};
|
||||
--code-punctuation: #{$semantic-color-text-secondary};
|
||||
--code-variable: #{$semantic-color-text-primary};
|
||||
--code-line-number: #{$semantic-color-text-tertiary};
|
||||
--code-line-highlight: #{$semantic-color-container-primary};
|
||||
--code-border: #{$semantic-color-border-secondary};
|
||||
}
|
||||
|
||||
.code-theme-github-dark {
|
||||
--code-bg: #0d1117;
|
||||
--code-surface: #161b22;
|
||||
--code-text: #f0f6fc;
|
||||
--code-comment: #8b949e;
|
||||
--code-keyword: #ff7b72;
|
||||
--code-string: #a5d6ff;
|
||||
--code-number: #79c0ff;
|
||||
--code-function: #d2a8ff;
|
||||
--code-operator: #f0f6fc;
|
||||
--code-punctuation: #c9d1d9;
|
||||
--code-variable: #f0f6fc;
|
||||
--code-line-number: #8b949e;
|
||||
--code-line-highlight: rgba(255, 211, 61, 0.08);
|
||||
--code-border: #30363d;
|
||||
}
|
||||
|
||||
.code-theme-one-dark {
|
||||
--code-bg: #282c34;
|
||||
--code-surface: #3e4451;
|
||||
--code-text: #abb2bf;
|
||||
--code-comment: #5c6370;
|
||||
--code-keyword: #c678dd;
|
||||
--code-string: #98c379;
|
||||
--code-number: #d19a66;
|
||||
--code-function: #61afef;
|
||||
--code-operator: #abb2bf;
|
||||
--code-punctuation: #abb2bf;
|
||||
--code-variable: #e06c75;
|
||||
--code-line-number: #5c6370;
|
||||
--code-line-highlight: #3e4451;
|
||||
--code-border: #3e4451;
|
||||
}
|
||||
|
||||
.code-theme-material {
|
||||
--code-bg: #263238;
|
||||
--code-surface: #37474f;
|
||||
--code-text: #eeffff;
|
||||
--code-comment: #546e7a;
|
||||
--code-keyword: #c792ea;
|
||||
--code-string: #c3e88d;
|
||||
--code-number: #f78c6c;
|
||||
--code-function: #82aaff;
|
||||
--code-operator: #89ddff;
|
||||
--code-punctuation: #89ddff;
|
||||
--code-variable: #eeffff;
|
||||
--code-line-number: #546e7a;
|
||||
--code-line-highlight: #37474f;
|
||||
--code-border: #37474f;
|
||||
}
|
||||
|
||||
.code-theme-dracula {
|
||||
--code-bg: #282a36;
|
||||
--code-surface: #44475a;
|
||||
--code-text: #f8f8f2;
|
||||
--code-comment: #6272a4;
|
||||
--code-keyword: #ff79c6;
|
||||
--code-string: #f1fa8c;
|
||||
--code-number: #bd93f9;
|
||||
--code-function: #8be9fd;
|
||||
--code-operator: #ff79c6;
|
||||
--code-punctuation: #f8f8f2;
|
||||
--code-variable: #f8f8f2;
|
||||
--code-line-number: #6272a4;
|
||||
--code-line-highlight: #44475a;
|
||||
--code-border: #44475a;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Component, Input, Output, EventEmitter, 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 CodeSnippetSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-code-snippet',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CopyButtonDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="containerClasses()">
|
||||
@if (title || showLanguage || copyable) {
|
||||
<div class="code-snippet__header">
|
||||
@if (title) {
|
||||
<span class="code-snippet__title">{{ title }}</span>
|
||||
}
|
||||
|
||||
<div class="code-snippet__actions">
|
||||
@if (showLanguage && finalLanguage()) {
|
||||
<span class="code-snippet__language-badge">{{ finalLanguage() }}</span>
|
||||
}
|
||||
|
||||
@if (copyable) {
|
||||
<button
|
||||
type="button"
|
||||
class="code-snippet__copy-btn"
|
||||
[uiCopyButton]="code"
|
||||
[attr.aria-label]="'Copy code to clipboard'"
|
||||
title="Copy code">
|
||||
<svg width="16" height="16" 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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="code-snippet__content">
|
||||
<pre [class]="preClasses()">
|
||||
@if (showLineNumbers && lineCount() > 1) {
|
||||
<div class="code-snippet__line-numbers" [attr.aria-hidden]="true">
|
||||
@for (lineNum of lineNumbers(); track lineNum) {
|
||||
<span
|
||||
class="code-snippet__line-number"
|
||||
[class.code-snippet__line-number--highlighted]="isLineHighlighted(lineNum)">
|
||||
{{ lineNum }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<code
|
||||
[class]="codeClasses()"
|
||||
[innerHTML]="highlightedCode()">
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@if (maxHeight && !expanded() && isOverflowing()) {
|
||||
<div class="code-snippet__expand">
|
||||
<button
|
||||
type="button"
|
||||
class="code-snippet__expand-btn"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()">
|
||||
Show {{ isOverflowing() ? 'more' : 'less' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './code-snippet.component.scss'
|
||||
})
|
||||
export class CodeSnippetComponent implements OnInit, OnChanges {
|
||||
@Input() code = '';
|
||||
@Input() language = '';
|
||||
@Input() title = '';
|
||||
@Input() theme: CodeTheme | null = null;
|
||||
@Input() size: CodeSnippetSize = 'md';
|
||||
@Input() showLineNumbers = false;
|
||||
@Input() copyable = true;
|
||||
@Input() showLanguage = true;
|
||||
@Input() highlightLines: (number | string)[] = [];
|
||||
@Input() maxHeight: string | null = null;
|
||||
@Input() wrap = false;
|
||||
@Input() startLineNumber = 1;
|
||||
|
||||
@Output() codeChange = new EventEmitter<string>();
|
||||
@Output() languageChange = new EventEmitter<string>();
|
||||
|
||||
readonly expanded = signal(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(() => {
|
||||
return this.code ? this.code.split('\n').length : 0;
|
||||
});
|
||||
|
||||
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-snippet'];
|
||||
classes.push(`code-snippet--${this.size}`);
|
||||
classes.push(`code-theme-${this.currentTheme()}`);
|
||||
|
||||
if (this.wrap) classes.push('code-snippet--wrap');
|
||||
if (this.maxHeight && !this.expanded()) classes.push('code-snippet--constrained');
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
readonly preClasses = computed(() => {
|
||||
const classes = ['code-snippet__pre'];
|
||||
if (this.showLineNumbers && this.lineCount() > 1) {
|
||||
classes.push('code-snippet__pre--with-line-numbers');
|
||||
}
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
readonly codeClasses = computed(() => {
|
||||
const classes = ['code-snippet__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;
|
||||
});
|
||||
}
|
||||
|
||||
isOverflowing(): boolean {
|
||||
// This would need to be implemented with ViewChild and element measurements
|
||||
// For now, return true if content is long
|
||||
return this.lineCount() > 20;
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update(val => !val);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
@use '../../../../../ui-design-system/src/styles/semantic/index' as *;
|
||||
|
||||
// Inline code component styles with design token integration
|
||||
.inline-code {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $semantic-spacing-component-xs;
|
||||
background: var(--code-surface, #{$semantic-color-surface-secondary});
|
||||
border: $semantic-border-input-width $semantic-border-input-style var(--code-border, #{$semantic-color-border-secondary});
|
||||
border-radius: $semantic-border-input-radius;
|
||||
font-family: 'Roboto Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
// Size variants
|
||||
&--xs {
|
||||
padding: $semantic-spacing-component-xs;
|
||||
font-size: $base-typography-font-size-xs;
|
||||
}
|
||||
|
||||
&--sm {
|
||||
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||
font-size: $base-typography-font-size-sm;
|
||||
}
|
||||
|
||||
&--md {
|
||||
padding: $semantic-spacing-component-sm;
|
||||
font-size: $base-typography-font-size-sm;
|
||||
}
|
||||
|
||||
&--lg {
|
||||
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
|
||||
font-size: $base-typography-font-size-md;
|
||||
}
|
||||
|
||||
// Copyable variant
|
||||
&--copyable {
|
||||
padding-right: calc(#{$semantic-spacing-component-sm} + 1.5rem + #{$semantic-spacing-component-xs});
|
||||
}
|
||||
}
|
||||
|
||||
.inline-code__code {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
|
||||
// Syntax highlighting styles (simplified for inline use)
|
||||
.token.comment {
|
||||
color: var(--code-comment, #{$semantic-color-text-tertiary});
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.keyword {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
font-weight: $base-typography-font-weight-medium;
|
||||
}
|
||||
|
||||
.token.string {
|
||||
color: var(--code-string, #{$semantic-color-success});
|
||||
}
|
||||
|
||||
.token.number {
|
||||
color: var(--code-number, #{$semantic-color-warning});
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: var(--code-function, #{$semantic-color-interactive-primary});
|
||||
}
|
||||
|
||||
.token.operator {
|
||||
color: var(--code-operator, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: var(--code-punctuation, #{$semantic-color-text-secondary});
|
||||
}
|
||||
|
||||
.token.variable {
|
||||
color: var(--code-variable, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
.token.boolean,
|
||||
.token.constant {
|
||||
color: var(--code-keyword, #{$semantic-color-brand-primary});
|
||||
}
|
||||
}
|
||||
|
||||
.inline-code__copy-btn {
|
||||
position: absolute;
|
||||
right: $semantic-spacing-component-xs;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--code-comment, #{$semantic-color-text-tertiary});
|
||||
border-radius: calc(#{$semantic-border-button-radius} * 0.5);
|
||||
cursor: pointer;
|
||||
transition: all $semantic-duration-fast $semantic-easing-standard;
|
||||
opacity: 0;
|
||||
|
||||
.inline-code:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--code-bg, #{$semantic-color-surface-primary});
|
||||
color: var(--code-text, #{$semantic-color-text-primary});
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-50%) scale(0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--code-keyword, #{$semantic-color-focus});
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme inheritance from parent
|
||||
.inline-code {
|
||||
// Themes are handled by parent code-theme- classes
|
||||
// or can be applied directly to inline-code elements
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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 InlineCodeSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-inline-code',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CopyButtonDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<span [class]="containerClasses()">
|
||||
<code
|
||||
[class]="codeClasses()"
|
||||
[innerHTML]="highlightedCode()">
|
||||
</code>
|
||||
|
||||
@if (copyable) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-code__copy-btn"
|
||||
[uiCopyButton]="code"
|
||||
[attr.aria-label]="'Copy code to clipboard'"
|
||||
title="Copy code">
|
||||
<svg width="12" height="12" 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>
|
||||
}
|
||||
</span>
|
||||
`,
|
||||
styleUrl: './inline-code.component.scss'
|
||||
})
|
||||
export class InlineCodeComponent implements OnInit, OnChanges {
|
||||
@Input() code = '';
|
||||
@Input() language = '';
|
||||
@Input() theme: CodeTheme | null = null;
|
||||
@Input() size: InlineCodeSize = 'md';
|
||||
@Input() copyable = false;
|
||||
|
||||
constructor(
|
||||
private syntaxHighlighter: SyntaxHighlighterService,
|
||||
private themeService: CodeThemeService
|
||||
) {}
|
||||
|
||||
readonly finalLanguage = computed(() => {
|
||||
if (this.language) {
|
||||
return this.language;
|
||||
}
|
||||
// For inline code, we're more conservative with auto-detection
|
||||
return 'text';
|
||||
});
|
||||
|
||||
readonly highlightedCode = signal('');
|
||||
|
||||
private async updateHighlightedCode() {
|
||||
if (!this.code) {
|
||||
this.highlightedCode.set('');
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = this.finalLanguage();
|
||||
if (lang === 'text') {
|
||||
this.highlightedCode.set(this.escapeHtml(this.code));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighted = await this.syntaxHighlighter.highlight(this.code, {
|
||||
language: lang
|
||||
});
|
||||
this.highlightedCode.set(highlighted);
|
||||
} catch (error) {
|
||||
console.warn('Failed to highlight code:', error);
|
||||
this.highlightedCode.set(this.escapeHtml(this.code));
|
||||
}
|
||||
}
|
||||
|
||||
readonly currentTheme = computed(() => {
|
||||
return this.theme || this.themeService.theme();
|
||||
});
|
||||
|
||||
readonly containerClasses = computed(() => {
|
||||
const classes = ['inline-code'];
|
||||
classes.push(`inline-code--${this.size}`);
|
||||
classes.push(`code-theme-${this.currentTheme()}`);
|
||||
|
||||
if (this.copyable) classes.push('inline-code--copyable');
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
readonly codeClasses = computed(() => {
|
||||
const classes = ['inline-code__code'];
|
||||
classes.push(`language-${this.finalLanguage()}`);
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Directive, Input, HostListener, inject, signal } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[uiCopyButton]',
|
||||
standalone: true
|
||||
})
|
||||
export class CopyButtonDirective {
|
||||
@Input() uiCopyButton!: string;
|
||||
@Input() copySuccessMessage = 'Copied!';
|
||||
@Input() copyErrorMessage = 'Failed to copy';
|
||||
|
||||
private copied = signal(false);
|
||||
private timeoutId: number | null = null;
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
async onClick(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.uiCopyButton) {
|
||||
console.warn('No content provided to copy');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.copyToClipboard(this.uiCopyButton);
|
||||
this.showFeedback(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
this.showFeedback(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
// Use modern clipboard API
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
this.fallbackCopyToClipboard(text);
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackCopyToClipboard(text: string): void {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (error) {
|
||||
throw new Error('Fallback copy failed');
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
private showFeedback(success: boolean): void {
|
||||
this.copied.set(success);
|
||||
|
||||
// Clear existing timeout
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
|
||||
// Reset after 2 seconds
|
||||
this.timeoutId = window.setTimeout(() => {
|
||||
this.copied.set(false);
|
||||
this.timeoutId = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
get isCopied(): boolean {
|
||||
return this.copied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface HighlightOptions {
|
||||
language: string;
|
||||
lineNumbers?: boolean;
|
||||
highlightLines?: (number | string)[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SyntaxHighlighterService {
|
||||
|
||||
private supportedLanguages = new Set([
|
||||
'javascript', 'typescript', 'css', 'scss', 'html', 'json',
|
||||
'bash', 'python', 'java', 'csharp', 'markup'
|
||||
]);
|
||||
|
||||
private prismLoaded = false;
|
||||
private loadPromise: Promise<any> | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async highlight(code: string, options: HighlightOptions): Promise<string> {
|
||||
const { language } = options;
|
||||
|
||||
if (!this.isLanguageSupported(language)) {
|
||||
return this.escapeHtml(code);
|
||||
}
|
||||
|
||||
try {
|
||||
const Prism = await this.loadPrism();
|
||||
await this.loadLanguage(language);
|
||||
|
||||
const grammar = Prism.languages[language];
|
||||
if (!grammar) {
|
||||
return this.escapeHtml(code);
|
||||
}
|
||||
|
||||
return Prism.highlight(code, grammar, language);
|
||||
} catch (error) {
|
||||
console.warn('Failed to highlight code:', error);
|
||||
return this.escapeHtml(code);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPrism(): Promise<any> {
|
||||
if (this.prismLoaded && (window as any).Prism) {
|
||||
return (window as any).Prism;
|
||||
}
|
||||
|
||||
if (this.loadPromise) {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
this.loadPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side rendering fallback
|
||||
resolve({ highlight: () => '', languages: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Prism core
|
||||
const prismModule = await import(/* @vite-ignore */ 'prismjs');
|
||||
const Prism = prismModule.default || prismModule;
|
||||
|
||||
// Ensure global availability
|
||||
(window as any).Prism = Prism;
|
||||
this.prismLoaded = true;
|
||||
|
||||
resolve(Prism);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load Prism:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private async loadLanguage(language: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const Prism = (window as any).Prism;
|
||||
if (!Prism) return;
|
||||
|
||||
// Check if language is already loaded
|
||||
if (Prism.languages[language]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Map of language identifiers to their import paths
|
||||
const languageMap: Record<string, string> = {
|
||||
'typescript': 'prismjs/components/prism-typescript',
|
||||
'javascript': 'prismjs/components/prism-javascript',
|
||||
'css': 'prismjs/components/prism-css',
|
||||
'scss': 'prismjs/components/prism-scss',
|
||||
'json': 'prismjs/components/prism-json',
|
||||
'markup': 'prismjs/components/prism-markup',
|
||||
'html': 'prismjs/components/prism-markup',
|
||||
'bash': 'prismjs/components/prism-bash',
|
||||
'python': 'prismjs/components/prism-python',
|
||||
'java': 'prismjs/components/prism-java',
|
||||
'csharp': 'prismjs/components/prism-csharp'
|
||||
};
|
||||
|
||||
const importPath = languageMap[language];
|
||||
if (importPath) {
|
||||
await import(/* @vite-ignore */ importPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load language ${language}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
isLanguageSupported(language: string): boolean {
|
||||
return this.supportedLanguages.has(language.toLowerCase());
|
||||
}
|
||||
|
||||
detectLanguage(code: string): string {
|
||||
// Simple heuristics for language detection
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
if (trimmedCode.startsWith('<?') || trimmedCode.includes('<html>') ||
|
||||
/<\/?[a-z][\s\S]*>/i.test(trimmedCode)) {
|
||||
return 'markup';
|
||||
}
|
||||
|
||||
if (trimmedCode.startsWith('{') || trimmedCode.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(trimmedCode);
|
||||
return 'json';
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('function ') || trimmedCode.includes('const ') ||
|
||||
trimmedCode.includes('let ') || trimmedCode.includes('var ')) {
|
||||
if (trimmedCode.includes(': ') && (trimmedCode.includes('interface ') ||
|
||||
trimmedCode.includes('type '))) {
|
||||
return 'typescript';
|
||||
}
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('#') && trimmedCode.includes('def ')) {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('public class ') || trimmedCode.includes('import java.')) {
|
||||
return 'java';
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('using System') || trimmedCode.includes('namespace ')) {
|
||||
return 'csharp';
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('#!/bin/') || trimmedCode.includes('echo ')) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (trimmedCode.includes('{') && (trimmedCode.includes('color:') ||
|
||||
trimmedCode.includes('margin:') || trimmedCode.includes('@'))) {
|
||||
return trimmedCode.includes('$') ? 'scss' : 'css';
|
||||
}
|
||||
|
||||
return 'text';
|
||||
}
|
||||
|
||||
getSupportedLanguages(): string[] {
|
||||
return Array.from(this.supportedLanguages).sort();
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
154
projects/ui-code-display/src/lib/services/theme.service.ts
Normal file
154
projects/ui-code-display/src/lib/services/theme.service.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export type CodeTheme = 'github-light' | 'github-dark' | 'one-dark' | 'material' | 'dracula';
|
||||
|
||||
export interface ThemeColors {
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
comment: string;
|
||||
keyword: string;
|
||||
string: string;
|
||||
number: string;
|
||||
function: string;
|
||||
operator: string;
|
||||
punctuation: string;
|
||||
variable: string;
|
||||
lineNumber: string;
|
||||
lineHighlight: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CodeThemeService {
|
||||
|
||||
private currentTheme = signal<CodeTheme>('github-light');
|
||||
|
||||
readonly theme = this.currentTheme.asReadonly();
|
||||
|
||||
readonly themeColors = computed(() => this.getThemeColors(this.currentTheme()));
|
||||
|
||||
private themes: Record<CodeTheme, ThemeColors> = {
|
||||
'github-light': {
|
||||
background: 'var(--semantic-color-surface-primary, #ffffff)',
|
||||
surface: 'var(--semantic-color-surface-secondary, #f6f8fa)',
|
||||
text: 'var(--semantic-color-text-primary, #24292f)',
|
||||
comment: 'var(--semantic-color-text-tertiary, #6e7781)',
|
||||
keyword: 'var(--semantic-color-brand-primary, #cf222e)',
|
||||
string: 'var(--semantic-color-success, #0a3069)',
|
||||
number: 'var(--semantic-color-warning, #0550ae)',
|
||||
function: 'var(--semantic-color-interactive-primary, #8250df)',
|
||||
operator: 'var(--semantic-color-text-primary, #24292f)',
|
||||
punctuation: 'var(--semantic-color-text-secondary, #656d76)',
|
||||
variable: 'var(--semantic-color-text-primary, #24292f)',
|
||||
lineNumber: 'var(--semantic-color-text-tertiary, #656d76)',
|
||||
lineHighlight: 'var(--semantic-color-container-primary, #fff8c5)',
|
||||
border: 'var(--semantic-color-border-secondary, #d0d7de)'
|
||||
},
|
||||
|
||||
'github-dark': {
|
||||
background: 'var(--semantic-color-surface-primary, #0d1117)',
|
||||
surface: 'var(--semantic-color-surface-secondary, #161b22)',
|
||||
text: 'var(--semantic-color-text-primary, #f0f6fc)',
|
||||
comment: 'var(--semantic-color-text-tertiary, #8b949e)',
|
||||
keyword: 'var(--semantic-color-brand-primary, #ff7b72)',
|
||||
string: 'var(--semantic-color-success, #a5d6ff)',
|
||||
number: 'var(--semantic-color-warning, #79c0ff)',
|
||||
function: 'var(--semantic-color-interactive-primary, #d2a8ff)',
|
||||
operator: 'var(--semantic-color-text-primary, #f0f6fc)',
|
||||
punctuation: 'var(--semantic-color-text-secondary, #c9d1d9)',
|
||||
variable: 'var(--semantic-color-text-primary, #f0f6fc)',
|
||||
lineNumber: 'var(--semantic-color-text-tertiary, #8b949e)',
|
||||
lineHighlight: 'var(--semantic-color-container-primary, #ffd33d14)',
|
||||
border: 'var(--semantic-color-border-secondary, #30363d)'
|
||||
},
|
||||
|
||||
'one-dark': {
|
||||
background: 'var(--semantic-color-surface-primary, #282c34)',
|
||||
surface: 'var(--semantic-color-surface-secondary, #3e4451)',
|
||||
text: 'var(--semantic-color-text-primary, #abb2bf)',
|
||||
comment: 'var(--semantic-color-text-tertiary, #5c6370)',
|
||||
keyword: 'var(--semantic-color-brand-primary, #c678dd)',
|
||||
string: 'var(--semantic-color-success, #98c379)',
|
||||
number: 'var(--semantic-color-warning, #d19a66)',
|
||||
function: 'var(--semantic-color-interactive-primary, #61afef)',
|
||||
operator: 'var(--semantic-color-text-primary, #abb2bf)',
|
||||
punctuation: 'var(--semantic-color-text-secondary, #abb2bf)',
|
||||
variable: 'var(--semantic-color-text-primary, #e06c75)',
|
||||
lineNumber: 'var(--semantic-color-text-tertiary, #5c6370)',
|
||||
lineHighlight: 'var(--semantic-color-container-primary, #3e4451)',
|
||||
border: 'var(--semantic-color-border-secondary, #3e4451)'
|
||||
},
|
||||
|
||||
'material': {
|
||||
background: 'var(--semantic-color-surface-primary, #263238)',
|
||||
surface: 'var(--semantic-color-surface-secondary, #37474f)',
|
||||
text: 'var(--semantic-color-text-primary, #eeffff)',
|
||||
comment: 'var(--semantic-color-text-tertiary, #546e7a)',
|
||||
keyword: 'var(--semantic-color-brand-primary, #c792ea)',
|
||||
string: 'var(--semantic-color-success, #c3e88d)',
|
||||
number: 'var(--semantic-color-warning, #f78c6c)',
|
||||
function: 'var(--semantic-color-interactive-primary, #82aaff)',
|
||||
operator: 'var(--semantic-color-text-primary, #89ddff)',
|
||||
punctuation: 'var(--semantic-color-text-secondary, #89ddff)',
|
||||
variable: 'var(--semantic-color-text-primary, #eeffff)',
|
||||
lineNumber: 'var(--semantic-color-text-tertiary, #546e7a)',
|
||||
lineHighlight: 'var(--semantic-color-container-primary, #37474f)',
|
||||
border: 'var(--semantic-color-border-secondary, #37474f)'
|
||||
},
|
||||
|
||||
'dracula': {
|
||||
background: 'var(--semantic-color-surface-primary, #282a36)',
|
||||
surface: 'var(--semantic-color-surface-secondary, #44475a)',
|
||||
text: 'var(--semantic-color-text-primary, #f8f8f2)',
|
||||
comment: 'var(--semantic-color-text-tertiary, #6272a4)',
|
||||
keyword: 'var(--semantic-color-brand-primary, #ff79c6)',
|
||||
string: 'var(--semantic-color-success, #f1fa8c)',
|
||||
number: 'var(--semantic-color-warning, #bd93f9)',
|
||||
function: 'var(--semantic-color-interactive-primary, #8be9fd)',
|
||||
operator: 'var(--semantic-color-text-primary, #ff79c6)',
|
||||
punctuation: 'var(--semantic-color-text-secondary, #f8f8f2)',
|
||||
variable: 'var(--semantic-color-text-primary, #f8f8f2)',
|
||||
lineNumber: 'var(--semantic-color-text-tertiary, #6272a4)',
|
||||
lineHighlight: 'var(--semantic-color-container-primary, #44475a)',
|
||||
border: 'var(--semantic-color-border-secondary, #44475a)'
|
||||
}
|
||||
};
|
||||
|
||||
setTheme(theme: CodeTheme): void {
|
||||
this.currentTheme.set(theme);
|
||||
}
|
||||
|
||||
getThemeColors(theme: CodeTheme): ThemeColors {
|
||||
return this.themes[theme];
|
||||
}
|
||||
|
||||
getAvailableThemes(): CodeTheme[] {
|
||||
return Object.keys(this.themes) as CodeTheme[];
|
||||
}
|
||||
|
||||
generateThemeCSS(theme: CodeTheme): string {
|
||||
const colors = this.getThemeColors(theme);
|
||||
|
||||
return `
|
||||
.code-theme-${theme} {
|
||||
--code-bg: ${colors.background};
|
||||
--code-surface: ${colors.surface};
|
||||
--code-text: ${colors.text};
|
||||
--code-comment: ${colors.comment};
|
||||
--code-keyword: ${colors.keyword};
|
||||
--code-string: ${colors.string};
|
||||
--code-number: ${colors.number};
|
||||
--code-function: ${colors.function};
|
||||
--code-operator: ${colors.operator};
|
||||
--code-punctuation: ${colors.punctuation};
|
||||
--code-variable: ${colors.variable};
|
||||
--code-line-number: ${colors.lineNumber};
|
||||
--code-line-highlight: ${colors.lineHighlight};
|
||||
--code-border: ${colors.border};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { UiCodeDisplayComponent } from './ui-code-display.component';
|
||||
|
||||
describe('UiCodeDisplayComponent', () => {
|
||||
let component: UiCodeDisplayComponent;
|
||||
let fixture: ComponentFixture<UiCodeDisplayComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UiCodeDisplayComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UiCodeDisplayComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-ui-code-display',
|
||||
imports: [],
|
||||
template: `
|
||||
<p>
|
||||
ui-code-display works!
|
||||
</p>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export class UiCodeDisplayComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { UiCodeDisplayService } from './ui-code-display.service';
|
||||
|
||||
describe('UiCodeDisplayService', () => {
|
||||
let service: UiCodeDisplayService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(UiCodeDisplayService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UiCodeDisplayService {
|
||||
|
||||
constructor() { }
|
||||
}
|
||||
19
projects/ui-code-display/src/public-api.ts
Normal file
19
projects/ui-code-display/src/public-api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Public API Surface of ui-code-display
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './lib/components/code-snippet/code-snippet.component';
|
||||
export * from './lib/components/inline-code/inline-code.component';
|
||||
export * from './lib/components/code-block/code-block.component';
|
||||
|
||||
// Directives
|
||||
export * from './lib/directives/copy-button.directive';
|
||||
|
||||
// Services
|
||||
export * from './lib/services/syntax-highlighter.service';
|
||||
export * from './lib/services/theme.service';
|
||||
|
||||
// Legacy exports (to be removed)
|
||||
export * from './lib/ui-code-display.service';
|
||||
export * from './lib/ui-code-display.component';
|
||||
Reference in New Issue
Block a user