feat(project-editor): syntax-highlighted code editor for HTML raw mode
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
src/app/shared/ui/code-editor/code-editor.component.html
Normal file
14
src/app/shared/ui/code-editor/code-editor.component.html
Normal 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>
|
||||||
87
src/app/shared/ui/code-editor/code-editor.component.scss
Normal file
87
src/app/shared/ui/code-editor/code-editor.component.scss
Normal 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);
|
||||||
|
}
|
||||||
39
src/app/shared/ui/code-editor/code-editor.component.ts
Normal file
39
src/app/shared/ui/code-editor/code-editor.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
src/app/shared/ui/code-editor/code-highlight.ts
Normal file
167
src/app/shared/ui/code-editor/code-highlight.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
export interface HighlightToken {
|
||||||
|
text: string;
|
||||||
|
cls: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').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 `<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('');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user