diff --git a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html index 32a566c..10b3428 100644 --- a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html +++ b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html @@ -7,13 +7,13 @@ @if (showCode()) { - + (valueChange)="updateCode($event)" + /> @if (codeError()) {

{{ codeError() }}

} diff --git a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss index 6d3c5af..fd78843 100644 --- a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss +++ b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss @@ -18,18 +18,6 @@ overflow-y: auto; } -.html-editor-code { - min-height: 160px; - font-family: monospace; - border: 1px solid var(--border, #ccc); - border-radius: 4px; - padding: 0.5rem; - - &--invalid { - border-color: var(--error-color, #991b1b); - } -} - .html-editor-code-error { margin: 0; color: var(--error-color, #991b1b); diff --git a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts index 18f83a4..6b236cb 100644 --- a/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts +++ b/src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts @@ -2,6 +2,7 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmi import { TranslateService } from '../../../../i18n/translate.service'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { validateHtml } from '../../schema/validators/primitives'; +import { CodeEditorComponent } from '../../../../shared/ui/code-editor/code-editor.component'; export interface HtmlEditorToolbarCommand { id: string; @@ -29,7 +30,7 @@ export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [ @Component({ selector: 'app-marketplace-html-editor', standalone: true, - imports: [TranslatePipe], + imports: [TranslatePipe, CodeEditorComponent], templateUrl: './marketplace-html-editor.component.html', styleUrls: ['./marketplace-html-editor.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/shared/ui/code-editor/code-editor.component.html b/src/app/shared/ui/code-editor/code-editor.component.html new file mode 100644 index 0000000..211b146 --- /dev/null +++ b/src/app/shared/ui/code-editor/code-editor.component.html @@ -0,0 +1,14 @@ +
+
+ +
diff --git a/src/app/shared/ui/code-editor/code-editor.component.scss b/src/app/shared/ui/code-editor/code-editor.component.scss new file mode 100644 index 0000000..12f6a79 --- /dev/null +++ b/src/app/shared/ui/code-editor/code-editor.component.scss @@ -0,0 +1,87 @@ +:host { + display: block; +} + +.code-editor { + position: relative; + border: 1px solid var(--border-color, #d3dad9); + border-radius: 10px; + overflow: hidden; +} + +.code-editor--invalid { + border-color: var(--error-color, #991b1b); +} + +.code-editor--focused { + border-color: #497671; + box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.15); +} + +.code-editor-highlight, +.code-editor-input { + margin: 0; + padding: 10px 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.5; + white-space: pre; + overflow: auto; +} + +.code-editor-highlight { + position: absolute; + inset: 0; + pointer-events: none; + color: var(--text-primary, #1e3c38); + background: #fbfcfc; +} + +.code-editor-input { + position: relative; + width: 100%; + border: 0; + resize: vertical; + background: transparent; + color: transparent; + caret-color: var(--text-primary, #1e3c38); +} + +.code-editor-input::selection { + background: rgba(73, 118, 113, 0.25); +} + +:global(.cm-comment) { + color: #7f8c8d; + font-style: italic; +} + +:global(.cm-string) { + color: #2f7d5e; +} + +:global(.cm-tag) { + color: #6d3fa8; +} + +:global(.cm-selector) { + color: #b2452f; + font-weight: 600; +} + +:global(.cm-property) { + color: #1f6f8b; +} + +:global(.cm-value) { + color: #1e3c38; +} + +:global(.cm-keyword) { + color: #6d3fa8; + font-weight: 600; +} + +:global(.cm-punct) { + color: var(--text-secondary, #5f6e6a); +} diff --git a/src/app/shared/ui/code-editor/code-editor.component.ts b/src/app/shared/ui/code-editor/code-editor.component.ts new file mode 100644 index 0000000..12332f5 --- /dev/null +++ b/src/app/shared/ui/code-editor/code-editor.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, ElementRef, ViewChild, computed, input, output, signal } from '@angular/core'; +import { renderHighlighted, tokenizeCss, tokenizeHtml } from './code-highlight'; + +export type CodeEditorLanguage = 'html' | 'css'; + +@Component({ + selector: 'app-code-editor', + standalone: true, + templateUrl: './code-editor.component.html', + styleUrl: './code-editor.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CodeEditorComponent { + readonly value = input(''); + readonly language = input('html'); + readonly rows = input(10); + readonly invalid = input(false); + + readonly valueChange = output(); + + @ViewChild('textarea', { static: true }) private textarea!: ElementRef; + @ViewChild('highlightLayer', { static: true }) private highlightLayer!: ElementRef; + + protected readonly highlighted = computed(() => { + const tokens = this.language() === 'css' ? tokenizeCss(this.value()) : tokenizeHtml(this.value()); + return renderHighlighted(tokens) + '\n'; + }); + + protected readonly focused = signal(false); + + protected onInput(event: Event): void { + this.valueChange.emit((event.target as HTMLTextAreaElement).value); + } + + protected syncScroll(): void { + this.highlightLayer.nativeElement.scrollTop = this.textarea.nativeElement.scrollTop; + this.highlightLayer.nativeElement.scrollLeft = this.textarea.nativeElement.scrollLeft; + } +} diff --git a/src/app/shared/ui/code-editor/code-highlight.ts b/src/app/shared/ui/code-editor/code-highlight.ts new file mode 100644 index 0000000..7dc04b5 --- /dev/null +++ b/src/app/shared/ui/code-editor/code-highlight.ts @@ -0,0 +1,167 @@ +export interface HighlightToken { + text: string; + cls: string | null; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Single-pass CSS tokenizer. Tracks brace depth + a selector/property/value + * mode so the same bare-word token (e.g. `color`) is classified differently + * depending on where it sits, without a full CSS parser. + */ +export function tokenizeCss(css: string): HighlightToken[] { + const tokens: HighlightToken[] = []; + let i = 0; + let braceDepth = 0; + let mode: 'selector' | 'property' | 'value' = 'selector'; + + while (i < css.length) { + if (css.startsWith('/*', i)) { + const end = css.indexOf('*/', i + 2); + const stop = end === -1 ? css.length : end + 2; + tokens.push({ text: css.slice(i, stop), cls: 'cm-comment' }); + i = stop; + continue; + } + const ch = css[i]; + if (ch === '"' || ch === "'") { + let j = i + 1; + while (j < css.length && css[j] !== ch) { + if (css[j] === '\\') j++; + j++; + } + j = Math.min(j + 1, css.length); + tokens.push({ text: css.slice(i, j), cls: 'cm-string' }); + i = j; + continue; + } + if (ch === '{') { + tokens.push({ text: '{', cls: 'cm-punct' }); + braceDepth++; + mode = 'property'; + i++; + continue; + } + if (ch === '}') { + tokens.push({ text: '}', cls: 'cm-punct' }); + braceDepth = Math.max(0, braceDepth - 1); + mode = braceDepth > 0 ? 'property' : 'selector'; + i++; + continue; + } + if (ch === ':' && braceDepth > 0 && mode === 'property') { + tokens.push({ text: ':', cls: 'cm-punct' }); + mode = 'value'; + i++; + continue; + } + if (ch === ';') { + tokens.push({ text: ';', cls: 'cm-punct' }); + mode = braceDepth > 0 ? 'property' : mode; + i++; + continue; + } + if (ch === ',') { + tokens.push({ text: ',', cls: 'cm-punct' }); + i++; + continue; + } + let j = i; + while (j < css.length && !'{}:;,"\''.includes(css[j]) && !css.startsWith('/*', j)) { + j++; + } + if (j === i) { + j = i + 1; + } + const text = css.slice(i, j); + const cls = mode === 'selector' ? 'cm-selector' : mode === 'property' ? (/^\s*@/.test(text) ? 'cm-keyword' : 'cm-property') : 'cm-value'; + tokens.push({ text, cls }); + i = j; + } + return tokens; +} + +/** Colors a whole `` as one token, except quoted attribute values (strings). */ +function tokenizeTag(tagText: string): HighlightToken[] { + const tokens: HighlightToken[] = []; + const stringRe = /"[^"]*"|'[^']*'/g; + let last = 0; + let match: RegExpExecArray | null; + while ((match = stringRe.exec(tagText))) { + if (match.index > last) { + tokens.push({ text: tagText.slice(last, match.index), cls: 'cm-tag' }); + } + tokens.push({ text: match[0], cls: 'cm-string' }); + last = stringRe.lastIndex; + } + if (last < tagText.length) { + tokens.push({ text: tagText.slice(last), cls: 'cm-tag' }); + } + return tokens; +} + +/** + * HTML tokenizer: comments + tags get coarse coloring (fine-grained for + * quoted attribute values), plain text stays uncolored. Content inside + * `` is delegated to the CSS tokenizer, since that is + * where marketplace static pages actually author CSS. + */ +export function tokenizeHtml(html: string): HighlightToken[] { + const tokens: HighlightToken[] = []; + let i = 0; + + while (i < html.length) { + if (html.startsWith('', i + 4); + const stop = end === -1 ? html.length : end + 3; + tokens.push({ text: html.slice(i, stop), cls: 'cm-comment' }); + i = stop; + continue; + } + if (html[i] === '<') { + let j = i + 1; + let inStr: string | null = null; + while (j < html.length) { + const ch = html[j]; + if (inStr) { + if (ch === inStr) inStr = null; + j++; + continue; + } + if (ch === '"' || ch === "'") { + inStr = ch; + j++; + continue; + } + if (ch === '>') { + j++; + break; + } + j++; + } + const tagText = html.slice(i, j); + tokens.push(...tokenizeTag(tagText)); + const isStyleOpenTag = /^]*)?>$/i.test(tagText); + i = j; + if (isStyleOpenTag) { + const closeIdx = html.toLowerCase().indexOf(' (token.cls ? `${escapeHtml(token.text)}` : escapeHtml(token.text))).join(''); +}