feat(project-editor): syntax-highlighted code editor for HTML raw mode
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

- shared app-code-editor: overlay textarea + highlighted <pre> layer,
  no external dependency (Monaco/CodeMirror)
- tokenizeCss: selector/property/value/string/comment/at-rule aware,
  brace-depth state machine
- tokenizeHtml: tags + comments colored, delegates <style> block content
  to tokenizeCss (that's where static-page CSS is actually authored)
- marketplace-html-editor raw-code mode now uses app-code-editor instead
  of a plain textarea

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 16:28:53 +04:00
parent 4543a6b6b6
commit 2a8c1166b1
7 changed files with 315 additions and 19 deletions

View File

@@ -7,13 +7,13 @@
</div> </div>
@if (showCode()) { @if (showCode()) {
<textarea <app-code-editor
class="html-editor-code" language="html"
[class.html-editor-code--invalid]="codeError()" [rows]="10"
rows="10" [invalid]="!!codeError()"
[value]="codeValue()" [value]="codeValue()"
(input)="updateCode($any($event.target).value)" (valueChange)="updateCode($event)"
></textarea> />
@if (codeError()) { @if (codeError()) {
<p class="html-editor-code-error">{{ codeError() }}</p> <p class="html-editor-code-error">{{ codeError() }}</p>
} }

View File

@@ -18,18 +18,6 @@
overflow-y: auto; 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 { .html-editor-code-error {
margin: 0; margin: 0;
color: var(--error-color, #991b1b); color: var(--error-color, #991b1b);

View File

@@ -2,6 +2,7 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmi
import { TranslateService } from '../../../../i18n/translate.service'; import { TranslateService } from '../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { validateHtml } from '../../schema/validators/primitives'; import { validateHtml } from '../../schema/validators/primitives';
import { CodeEditorComponent } from '../../../../shared/ui/code-editor/code-editor.component';
export interface HtmlEditorToolbarCommand { export interface HtmlEditorToolbarCommand {
id: string; id: string;
@@ -29,7 +30,7 @@ export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
@Component({ @Component({
selector: 'app-marketplace-html-editor', selector: 'app-marketplace-html-editor',
standalone: true, standalone: true,
imports: [TranslatePipe], imports: [TranslatePipe, CodeEditorComponent],
templateUrl: './marketplace-html-editor.component.html', templateUrl: './marketplace-html-editor.component.html',
styleUrls: ['./marketplace-html-editor.component.scss'], styleUrls: ['./marketplace-html-editor.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -0,0 +1,14 @@
<div class="code-editor" [class.code-editor--invalid]="invalid()" [class.code-editor--focused]="focused()">
<pre #highlightLayer class="code-editor-highlight" [attr.rows]="rows()"><code [innerHTML]="highlighted()"></code></pre>
<textarea
#textarea
class="code-editor-input"
[rows]="rows()"
spellcheck="false"
[value]="value()"
(input)="onInput($event)"
(scroll)="syncScroll()"
(focus)="focused.set(true)"
(blur)="focused.set(false)"
></textarea>
</div>

View File

@@ -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);
}

View File

@@ -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<CodeEditorLanguage>('html');
readonly rows = input(10);
readonly invalid = input(false);
readonly valueChange = output<string>();
@ViewChild('textarea', { static: true }) private textarea!: ElementRef<HTMLTextAreaElement>;
@ViewChild('highlightLayer', { static: true }) private highlightLayer!: ElementRef<HTMLElement>;
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;
}
}

View File

@@ -0,0 +1,167 @@
export interface HighlightToken {
text: string;
cls: string | null;
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* 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 `<tag ...>` 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
* `<style>...</style>` 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)) {
const end = html.indexOf('-->', 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 = /^<style(\s[^>]*)?>$/i.test(tagText);
i = j;
if (isStyleOpenTag) {
const closeIdx = html.toLowerCase().indexOf('</style', i);
const contentEnd = closeIdx === -1 ? html.length : closeIdx;
tokens.push(...tokenizeCss(html.slice(i, contentEnd)));
i = contentEnd;
}
continue;
}
let j = html.indexOf('<', i);
if (j === -1) j = html.length;
tokens.push({ text: html.slice(i, j), cls: null });
i = j;
}
return tokens;
}
export function renderHighlighted(tokens: HighlightToken[]): string {
return tokens.map(token => (token.cls ? `<span class="${token.cls}">${escapeHtml(token.text)}</span>` : escapeHtml(token.text))).join('');
}