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 = /^ (token.cls ? `${escapeHtml(token.text)}` : escapeHtml(token.text))).join('');
+}